Skip to content

Commit a10792a

Browse files
chrisdpurcellclaude
andcommitted
feat: add Ctrl+F, Ctrl+H, F3 keyboard shortcuts
Wires three QShortcut objects (ApplicationShortcut context) in _connect_signals: Ctrl+F and Ctrl+H switch to the Find/Replace tab and focus the respective field; F3 triggers _on_find_clicked. Adds _focus_find_edit/_focus_replace_edit helpers and caches _tab_widget + _find_replace_tab_index at load time. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 4eecf1a commit a10792a

2 files changed

Lines changed: 107 additions & 2 deletions

File tree

src/views/main_window.py

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
import os
1717
from typing import TypeVar, cast
1818

19-
from PySide6.QtCore import QDir, QFile, QModelIndex
20-
from PySide6.QtGui import QAction
19+
from PySide6.QtCore import QDir, QFile, QModelIndex, Qt
20+
from PySide6.QtGui import QAction, QKeySequence, QShortcut
2121
from PySide6.QtUiTools import QUiLoader
2222
from PySide6.QtWidgets import (
2323
QApplication,
@@ -30,6 +30,7 @@
3030
QMessageBox,
3131
QPlainTextEdit,
3232
QPushButton,
33+
QTabWidget,
3334
QTreeView,
3435
)
3536

@@ -161,6 +162,20 @@ def _load_ui(self) -> None:
161162
self._action_preferences = _require(
162163
self.ui.findChild(QAction, "actionPreferences"), "actionPreferences"
163164
)
165+
# Tab widget — needed by focus-navigation shortcuts to reveal Find/Replace tab.
166+
self._tab_widget = _require(
167+
self.ui.findChild(QTabWidget, "tabWidget"), "tabWidget"
168+
)
169+
# Find/Replace is the last tab; cache its index so shortcuts stay correct
170+
# if tabs are reordered. Falls back to -1 (no-op) if tab is not found.
171+
self._find_replace_tab_index = next(
172+
(
173+
i
174+
for i in range(self._tab_widget.count())
175+
if self._tab_widget.tabText(i).lower().startswith("find")
176+
),
177+
-1,
178+
)
164179

165180
def _setup_file_tree(self) -> None:
166181
"""Configure QFileSystemModel rooted at the user's home directory."""
@@ -224,8 +239,37 @@ def _connect_signals(self) -> None:
224239
lambda _: self._update_title()
225240
)
226241

242+
# Keyboard shortcuts not present in the .ui file.
243+
# (Ctrl+S/O/Q/Shift+S are already wired via QAction shortcuts in main_window.ui.)
244+
# ApplicationShortcut context: fires even when a child widget holds focus,
245+
# rather than requiring the window itself to be active. Necessary for focus
246+
# navigation shortcuts that must work regardless of which widget is focused.
247+
ctrl_f = QShortcut(QKeySequence("Ctrl+F"), self.ui)
248+
ctrl_f.setContext(Qt.ShortcutContext.ApplicationShortcut)
249+
ctrl_f.activated.connect(self._focus_find_edit)
250+
251+
ctrl_h = QShortcut(QKeySequence("Ctrl+H"), self.ui)
252+
ctrl_h.setContext(Qt.ShortcutContext.ApplicationShortcut)
253+
ctrl_h.activated.connect(self._focus_replace_edit)
254+
255+
f3 = QShortcut(QKeySequence("F3"), self.ui)
256+
f3.setContext(Qt.ShortcutContext.ApplicationShortcut)
257+
f3.activated.connect(self._on_find_clicked)
258+
227259
# ---------------------------------------------------------- user actions
228260

261+
def _focus_find_edit(self) -> None:
262+
"""Switch to Find/Replace tab and focus the find field (Ctrl+F target)."""
263+
if self._find_replace_tab_index >= 0:
264+
self._tab_widget.setCurrentIndex(self._find_replace_tab_index)
265+
self._find_edit.setFocus()
266+
267+
def _focus_replace_edit(self) -> None:
268+
"""Switch to Find/Replace tab and focus the replace field (Ctrl+H target)."""
269+
if self._find_replace_tab_index >= 0:
270+
self._tab_widget.setCurrentIndex(self._find_replace_tab_index)
271+
self._replace_edit.setFocus()
272+
229273
def _on_tree_item_clicked(self, index: QModelIndex) -> None:
230274
"""Load file on tree click; ignore directory clicks."""
231275
path = self._fs_model.filePath(index)

tests/integration/test_main_window.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,64 @@ def test_opens_on_clean_tab(self, window):
344344
tab = window.ui.findChild(QTabWidget, "tabWidget")
345345
assert tab is not None
346346
assert tab.currentIndex() == 0
347+
348+
349+
class TestKeyboardShortcuts:
350+
"""Verify QShortcut wiring by activating each shortcut's signal directly.
351+
352+
qtbot.keyClick sends QKeyEvent, which does not trigger QShortcut (those
353+
listen for QShortcutEvent dispatched by Qt's shortcut system). Emitting
354+
activated directly tests the actual signal-to-slot connection without
355+
depending on OS-level key event routing.
356+
357+
hasFocus() is unreliable in headless tests because it requires the window
358+
to hold true OS-level focus. Instead: for Ctrl+F and Ctrl+H, the observable
359+
side effect is the tab switching to Find/Replace — that is what we assert.
360+
For F3, the cursor position advancing to the second match is the assertion.
361+
"""
362+
363+
def _shortcut_for(self, window, key_string: str):
364+
"""Return the QShortcut whose key matches key_string, or raise."""
365+
from PySide6.QtGui import QKeySequence, QShortcut
366+
target = QKeySequence(key_string)
367+
shortcuts = window.ui.findChildren(QShortcut)
368+
for sc in shortcuts:
369+
if sc.key() == target:
370+
return sc
371+
raise AssertionError(f"No QShortcut with key '{key_string}' found on window.ui")
372+
373+
def test_ctrl_f_switches_to_find_replace_tab(self, window, qtbot):
374+
"""Ctrl+F switches to the Find/Replace tab (tab index matches _find_replace_tab_index)."""
375+
from PySide6.QtWidgets import QTabWidget
376+
tab = window.ui.findChild(QTabWidget, "tabWidget")
377+
assert tab.currentIndex() == 0, "precondition: starts on Clean tab"
378+
sc = self._shortcut_for(window, "Ctrl+F")
379+
sc.activated.emit()
380+
qtbot.wait(10)
381+
assert tab.currentIndex() == window._find_replace_tab_index
382+
383+
def test_ctrl_h_switches_to_find_replace_tab(self, window, qtbot):
384+
"""Ctrl+H switches to the Find/Replace tab (tab index matches _find_replace_tab_index)."""
385+
from PySide6.QtWidgets import QTabWidget
386+
tab = window.ui.findChild(QTabWidget, "tabWidget")
387+
assert tab.currentIndex() == 0, "precondition: starts on Clean tab"
388+
sc = self._shortcut_for(window, "Ctrl+H")
389+
sc.activated.emit()
390+
qtbot.wait(10)
391+
assert tab.currentIndex() == window._find_replace_tab_index
392+
393+
def test_f3_triggers_find_next(self, window, qtbot):
394+
"""F3 finds the next occurrence of the current search term."""
395+
window._plain_text_edit.setPlainText("hello world hello")
396+
window._find_edit.setText("hello")
397+
# First find via direct call establishes an initial selection
398+
window._on_find_clicked()
399+
assert window._plain_text_edit.textCursor().selectedText() == "hello"
400+
pos_after_first = window._plain_text_edit.textCursor().position()
401+
# F3 shortcut should advance to next occurrence
402+
sc = self._shortcut_for(window, "F3")
403+
sc.activated.emit()
404+
qtbot.wait(10)
405+
assert window._plain_text_edit.textCursor().selectedText() == "hello"
406+
pos_after_second = window._plain_text_edit.textCursor().position()
407+
assert pos_after_second > pos_after_first

0 commit comments

Comments
 (0)