diff --git a/app/gui/tabs/generate.py b/app/gui/tabs/generate.py index 7f7ce9e..2063030 100644 --- a/app/gui/tabs/generate.py +++ b/app/gui/tabs/generate.py @@ -2,6 +2,8 @@ from __future__ import annotations +from PySide6.QtCore import QEvent + from ..models import GenerateTaskTableModel from ..widgets import * from ..workers import GenerateWorker as _RealGenerateWorker @@ -148,6 +150,7 @@ class CoverGalleryDialog(QDialog): self._update_navigation_buttons() self._update_regenerate_button_state() self._fit_to_screen() + self._install_navigation_event_filters() def _old_cover_panel(self): panel = QWidget() @@ -458,24 +461,34 @@ class CoverGalleryDialog(QDialog): event.ignore() def keyPressEvent(self, event): - if event.key() == Qt.Key_Up and self._should_consume_navigation_key(): - self.switch_task(-1) - event.accept() - return - if event.key() == Qt.Key_Down and self._should_consume_navigation_key(): - self.switch_task(1) - event.accept() + if self._handle_navigation_key(event, QApplication.focusWidget()): return super().keyPressEvent(event) - def _should_consume_navigation_key(self): - focus = QApplication.focusWidget() + def eventFilter(self, watched, event): + focus = QApplication.focusWidget() if watched is self else watched + if event.type() == QEvent.KeyPress and self._handle_navigation_key(event, focus): + return True + return super().eventFilter(watched, event) + + def _handle_navigation_key(self, event, focus): + if event.key() == Qt.Key_Up and self._should_consume_navigation_key(focus): + self.switch_task(-1) + event.accept() + return True + if event.key() == Qt.Key_Down and self._should_consume_navigation_key(focus): + self.switch_task(1) + event.accept() + return True + return False + + def _should_consume_navigation_key(self, focus=None): + focus = focus or QApplication.focusWidget() if focus is None or focus is self: return True return not isinstance( focus, ( - QPushButton, QLineEdit, QPlainTextEdit, QComboBox, @@ -483,6 +496,10 @@ class CoverGalleryDialog(QDialog): ), ) + def _install_navigation_event_filters(self): + for widget in [self] + self.findChildren(QWidget): + widget.installEventFilter(self) + def open_original_image(self, image_path): dialog = OriginalImageDialog(image_path, self) dialog.exec() @@ -543,6 +560,7 @@ class CoverGalleryDialog(QDialog): empty_label.setMinimumSize(320, self.THUMBNAIL_SIZE) empty_label.setAlignment(Qt.AlignCenter) self.candidate_layout.addWidget(empty_label) + self._install_navigation_event_filters() def _on_regenerate_progress(self, payload): if payload.get("failed"): diff --git a/docs/tasks/T-572.md b/docs/tasks/T-572.md index 145a152..26aa8a6 100644 --- a/docs/tasks/T-572.md +++ b/docs/tasks/T-572.md @@ -3,7 +3,7 @@ id: T-572 title: ②封面画廊打开后上下键切换记录不受按钮焦点影响 phase: 7 deps: [T-569] -status: TODO +status: DONE created: 2026-07-09 --- @@ -55,4 +55,7 @@ return not isinstance(focus, (QPushButton, QLineEdit, QPlainTextEdit, QComboBox, ## 执行记录 -(做完在这里写:改了什么文件、跑了什么验证命令及结果、关键决策。) +- 2026-07-09:完成 T-572。 + - `app/gui/tabs/generate.py`:调整 `CoverGalleryDialog._should_consume_navigation_key()`,不再把 `QPushButton` 作为方向键切换的排除焦点;同时给弹窗和子控件安装 `eventFilter`,处理真实 Qt 运行时按键先发给按钮/radio 等子控件、父弹窗 `keyPressEvent()` 收不到的路径。焦点在上一条/下一条/重新生成/保存/取消等按钮上时,`↑ / ↓` 会切换记录;焦点在输入类控件上仍不抢键。 + - `tests/test_gui.py`:新增按钮焦点下通过真实子控件事件分发 `Qt.Key_Down` 可切到下一条的回归测试;新增输入框焦点下 `Qt.Key_Down` 不切换记录的保护测试;保留 radio 焦点不改变候选选择的既有测试。 + - 验证通过:`py -3.10 -m unittest tests.test_gui`、`python -m ruff check app tests main.py`、`py -3.10 -m compileall app main.py`、`py -3.10 -m unittest discover -s tests`、`git diff --check`。 diff --git a/tests/test_gui.py b/tests/test_gui.py index e100783..c515a57 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -2472,7 +2472,7 @@ class GuiTests(TempDirMixin, unittest.TestCase): dialog.candidate_buttons[canonicals[0]].setFocus() event = QKeyEvent(QKeyEvent.KeyPress, gui.Qt.Key_Down, gui.Qt.NoModifier) - dialog.keyPressEvent(event) + QApplication.sendEvent(dialog.candidate_buttons[canonicals[0]], event) self.assertTrue(event.isAccepted()) self.assertEqual(tasks[1].id, dialog.task.id) @@ -2481,6 +2481,56 @@ class GuiTests(TempDirMixin, unittest.TestCase): self.assert_removed(temp_dir) + def test_cover_gallery_arrow_keys_switch_when_button_has_focus(self): + with self.make_temp_dir() as temp_dir: + cfg, account, tasks, canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=2) + dialog = CoverGalleryDialog( + tasks[0], + cfg["image_dir"], + cfg["db_path"], + account=account, + visible_tasks=tasks, + task_index=0, + account_by_alias={"alias-a": account}, + ) + self.addCleanup(dialog.close) + dialog.next_button.setFocus() + event = QKeyEvent(QKeyEvent.KeyPress, gui.Qt.Key_Down, gui.Qt.NoModifier) + + QApplication.sendEvent(dialog.next_button, event) + + self.assertTrue(event.isAccepted()) + self.assertEqual(tasks[1].id, dialog.task.id) + self.assertEqual(canonicals[1], dialog.selected_path) + + self.assert_removed(temp_dir) + + def test_cover_gallery_arrow_keys_do_not_switch_when_text_input_has_focus(self): + with self.make_temp_dir() as temp_dir: + cfg, account, tasks, _canonicals, _archives = self._cover_gallery_task_set(temp_dir, count=2) + dialog = CoverGalleryDialog( + tasks[0], + cfg["image_dir"], + cfg["db_path"], + account=account, + visible_tasks=tasks, + task_index=0, + account_by_alias={"alias-a": account}, + ) + self.addCleanup(dialog.close) + input_widget = QLineEdit(dialog) + event = QKeyEvent(QKeyEvent.KeyPress, gui.Qt.Key_Down, gui.Qt.NoModifier) + + with mock.patch( + "app.gui.tabs.generate.QApplication.focusWidget", + return_value=input_widget, + ): + QApplication.sendEvent(input_widget, event) + + self.assertEqual(tasks[0].id, dialog.task.id) + + self.assert_removed(temp_dir) + def test_cover_gallery_unsaved_switch_save_discard_and_cancel_paths(self): with self.make_temp_dir() as temp_dir: cfg, account, tasks, canonicals, archives = self._cover_gallery_task_set(temp_dir, count=2)