Compare commits

..

2 Commits

Author SHA1 Message Date
wongyiekheng
21e16d9872 Fix search: Enter advances to next match, invalidate on new content
- Pressing Enter in find bar now advances to next match instead of
  re-running search when needle is unchanged
- Invalidate search cache when new log lines flush or log is cleared
- Extract _invalidate_search() helper for consistent state reset

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:00:06 +08:00
wongyiekheng
5b873aefc4 Rework keyboard shortcuts with app-level event filter
- Replace QShortcut with QApplication eventFilter to intercept
  Ctrl+C/S/N/W/D/F/R before widgets consume them
- New bindings: Ctrl+N (new tab), Ctrl+C (clear log), Ctrl+S (find),
  Ctrl+F (flash), Ctrl+D (connect/disconnect), Ctrl+R (normal mode)
- Single bindings tuple as source of truth for key map and help dialog
- Apply saved font settings via one-shot showEvent (after stylesheet)
- Add Shortcuts entry under Help menu
- Remove unused imports (QShortcut, QKeySequence, QMenuBar)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 10:00:00 +08:00
2 changed files with 74 additions and 23 deletions

View File

@ -4,20 +4,19 @@ import logging
import logging.handlers import logging.handlers
import sys import sys
from PySide6.QtCore import Qt from PySide6.QtCore import QEvent, Qt
from PySide6.QtGui import QFont, QKeySequence, QShortcut from PySide6.QtGui import QFont
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QInputDialog, QInputDialog,
QMainWindow, QMainWindow,
QMenuBar,
QMessageBox, QMessageBox,
QTabWidget, QTabWidget,
QToolButton, QToolButton,
) )
from ameba_control_panel import config from ameba_control_panel import config
from ameba_control_panel.config import DeviceProfile from ameba_control_panel.config import DeviceProfile, Mode
from ameba_control_panel import theme from ameba_control_panel import theme
from ameba_control_panel.controllers.device_tab_controller import DeviceTabController from ameba_control_panel.controllers.device_tab_controller import DeviceTabController
from ameba_control_panel.services.session_store import SessionStore from ameba_control_panel.services.session_store import SessionStore
@ -39,10 +38,11 @@ class MainWindow(QMainWindow):
self._settings = Settings() self._settings = Settings()
self._dut_controllers: list[DeviceTabController] = [] self._dut_controllers: list[DeviceTabController] = []
self._next_dut_num = 1 self._next_dut_num = 1
self._settings_applied = False
add_btn = QToolButton() add_btn = QToolButton()
add_btn.setText("+") add_btn.setText("+")
add_btn.setToolTip("Add DUT tab (Ctrl+T)") add_btn.setToolTip("Add DUT tab (Ctrl+N)")
add_btn.clicked.connect(self._add_dut_tab_auto) add_btn.clicked.connect(self._add_dut_tab_auto)
self._tabs.setCornerWidget(add_btn, Qt.TopLeftCorner) self._tabs.setCornerWidget(add_btn, Qt.TopLeftCorner)
@ -61,21 +61,28 @@ class MainWindow(QMainWindow):
else: else:
self._add_dut_tab("dut_1", "DUT 1") self._add_dut_tab("dut_1", "DUT 1")
def showEvent(self, event) -> None: # noqa: N802
super().showEvent(event)
if not self._settings_applied:
self._settings_applied = True
self._apply_settings()
def _build_menu_bar(self) -> None: def _build_menu_bar(self) -> None:
mb = self.menuBar() mb = self.menuBar()
view_menu = mb.addMenu("&View") view_menu = mb.addMenu("&View")
view_menu.addAction("New Tab", self._add_dut_tab_auto, QKeySequence("Ctrl+T")) view_menu.addAction("New Tab\tCtrl+N", self._add_dut_tab_auto)
view_menu.addAction("Close Tab", self._close_current_tab, QKeySequence("Ctrl+W")) view_menu.addAction("Close Tab\tCtrl+W", self._close_current_tab)
view_menu.addSeparator() view_menu.addSeparator()
view_menu.addAction("Clear Log", self._clear_current_log, QKeySequence("Ctrl+L")) view_menu.addAction("Clear Log\tCtrl+C", self._clear_current_log)
view_menu.addAction("Find", self._focus_find, QKeySequence("Ctrl+F")) view_menu.addAction("Find\tCtrl+S", self._focus_find)
settings_menu = mb.addMenu("&Settings") settings_menu = mb.addMenu("&Settings")
settings_menu.addAction("Preferences...", self._open_settings) settings_menu.addAction("Preferences...", self._open_settings)
help_menu = mb.addMenu("&Help") help_menu = mb.addMenu("&Help")
help_menu.addAction("About", self._show_about) help_menu.addAction("About", self._show_about)
help_menu.addAction("Shortcuts", self._show_shortcuts)
def _toggle_theme(self) -> None: def _toggle_theme(self) -> None:
if self._current_palette.name == "light": if self._current_palette.name == "light":
@ -87,6 +94,15 @@ class MainWindow(QMainWindow):
for ctrl in self._dut_controllers: for ctrl in self._dut_controllers:
ctrl.view.log_view.set_colors(p.log_rx, p.log_tx, p.log_info) ctrl.view.log_view.set_colors(p.log_rx, p.log_tx, p.log_info)
def _toggle_connect(self) -> None:
ctrl = self._current_controller()
if ctrl:
ctrl.view.connect_button.click()
def _show_shortcuts(self) -> None:
text = "\n".join(f"Ctrl+{lbl}\t{desc}" for lbl, desc in self._shortcut_labels)
QMessageBox.information(self, "Shortcuts", text)
def _show_about(self) -> None: def _show_about(self) -> None:
QMessageBox.about(self, config.APP_NAME, QMessageBox.about(self, config.APP_NAME,
f"{config.APP_NAME}\n" f"{config.APP_NAME}\n"
@ -96,14 +112,34 @@ class MainWindow(QMainWindow):
f"Licensed under the Apache License, Version 2.0\n" f"Licensed under the Apache License, Version 2.0\n"
f"All rights reserved. For internal use only.") f"All rights reserved. For internal use only.")
_KEY_EVENTS = frozenset({QEvent.KeyPress, QEvent.ShortcutOverride})
def _setup_shortcuts(self) -> None: def _setup_shortcuts(self) -> None:
QShortcut(QKeySequence("Ctrl+T"), self, activated=self._add_dut_tab_auto) bindings = (
QShortcut(QKeySequence("Ctrl+W"), self, activated=self._close_current_tab) ("N", "New DUT tab", Qt.Key_N, self._add_dut_tab_auto),
QShortcut(QKeySequence("Ctrl+F"), self, activated=self._focus_find) ("W", "Close current tab", Qt.Key_W, self._close_current_tab),
QShortcut(QKeySequence("Ctrl+L"), self, activated=self._clear_current_log) ("D", "Connect / Disconnect", Qt.Key_D, self._toggle_connect),
QShortcut(QKeySequence("Ctrl+Return"), self, activated=self._send_current) ("S", "Find in log", Qt.Key_S, self._focus_find),
QShortcut(QKeySequence("Ctrl+Shift+F"), self, activated=self._flash_current) ("C", "Clear log", Qt.Key_C, self._clear_current_log),
QShortcut(QKeySequence("Ctrl+R"), self, activated=self._reset_current) ("Enter", "Send command", Qt.Key_Return, self._send_current),
("F", "Flash firmware", Qt.Key_F, self._flash_current),
("R", "Normal mode", Qt.Key_R, self._reset_current),
)
self._shortcut_labels = tuple((lbl, desc) for lbl, desc, _, _ in bindings)
self._key_map = {key: handler for _, _, key, handler in bindings}
QApplication.instance().installEventFilter(self)
def eventFilter(self, obj, event) -> bool:
etype = event.type()
if etype in self._KEY_EVENTS and event.modifiers() == Qt.ControlModifier:
handler = self._key_map.get(event.key())
if handler:
if etype == QEvent.ShortcutOverride:
event.accept()
return True
handler()
return True
return super().eventFilter(obj, event)
def _close_current_tab(self) -> None: def _close_current_tab(self) -> None:
self._close_tab(self._tabs.currentIndex()) self._close_tab(self._tabs.currentIndex())
@ -146,8 +182,7 @@ class MainWindow(QMainWindow):
def _reset_current(self) -> None: def _reset_current(self) -> None:
ctrl = self._current_controller() ctrl = self._current_controller()
if ctrl: if ctrl:
from ameba_control_panel.config import Mode ctrl.flash.run_mode(Mode.NORMAL)
ctrl.flash.run_mode(Mode.RESET)
def _current_controller(self) -> DeviceTabController | None: def _current_controller(self) -> DeviceTabController | None:
widget = self._tabs.currentWidget() widget = self._tabs.currentWidget()

View File

@ -36,6 +36,8 @@ class LogManager(QObject):
self._search_worker: Optional[SearchWorker] = None self._search_worker: Optional[SearchWorker] = None
self._matches: List[int] = [] self._matches: List[int] = []
self._match_index = -1 self._match_index = -1
self._last_needle = ""
self._last_case = False
self._flush_timer = QTimer(self) self._flush_timer = QTimer(self)
self._flush_timer.setInterval(config.LOG_FLUSH_INTERVAL_MS) self._flush_timer.setInterval(config.LOG_FLUSH_INTERVAL_MS)
@ -87,6 +89,8 @@ class LogManager(QObject):
visible = [line for line in to_flush if line.direction != Direction.TX] visible = [line for line in to_flush if line.direction != Direction.TX]
if visible: if visible:
self.view.log_view.append_lines(visible) self.view.log_view.append_lines(visible)
if self._matches:
self._invalidate_search()
# Adaptive: slow down flush when queue is heavy to reduce UI stalls # Adaptive: slow down flush when queue is heavy to reduce UI stalls
pending_count = len(self._pending) pending_count = len(self._pending)
if pending_count > 500: if pending_count > 500:
@ -96,12 +100,17 @@ class LogManager(QObject):
else: else:
self._flush_timer.setInterval(config.LOG_FLUSH_INTERVAL_MS) self._flush_timer.setInterval(config.LOG_FLUSH_INTERVAL_MS)
def _invalidate_search(self) -> None:
self._matches.clear()
self._match_index = -1
self._last_needle = ""
self._last_case = False
self.view.log_view.set_matches([], -1)
def clear(self) -> None: def clear(self) -> None:
self.buffer.clear() self.buffer.clear()
self.view.log_view.clear_log() self.view.log_view.clear_log()
self._matches.clear() self._invalidate_search()
self._match_index = -1
self.view.log_view.set_matches([], -1)
def save(self) -> None: def save(self) -> None:
dlg = QFileDialog(self.view, "Save Log", str(Path.home() / "ameba_log.txt")) dlg = QFileDialog(self.view, "Save Log", str(Path.home() / "ameba_log.txt"))
@ -126,14 +135,21 @@ class LogManager(QObject):
if self._search_worker and self._search_worker.isRunning(): if self._search_worker and self._search_worker.isRunning():
return return
needle = self.view.find_input.text() needle = self.view.find_input.text()
self.view.log_view.set_needle(needle, self.view.case_checkbox.isChecked()) case = self.view.case_checkbox.isChecked()
# If needle unchanged and we already have matches, just advance
if needle == self._last_needle and case == self._last_case and self._matches:
self.find_next()
return
self._last_needle = needle
self._last_case = case
self.view.log_view.set_needle(needle, case)
if not needle: if not needle:
self.view.log_view.set_matches([], -1) self.view.log_view.set_matches([], -1)
self._matches = [] self._matches = []
self._match_index = -1 self._match_index = -1
return return
lines = [l.as_display() for l in self.view.log_view.displayed_lines()] lines = [l.as_display() for l in self.view.log_view.displayed_lines()]
self._search_worker = SearchWorker(lines, needle, self.view.case_checkbox.isChecked()) self._search_worker = SearchWorker(lines, needle, case)
self._search_worker.finished.connect(self._on_search_finished) self._search_worker.finished.connect(self._on_search_finished)
self._search_worker.start() self._search_worker.start()