from __future__ import annotations import logging import logging.handlers import sys from PySide6.QtCore import QEvent, Qt from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QApplication, QInputDialog, QMainWindow, QMessageBox, QTabWidget, QToolButton, ) from ameba_control_panel import config from ameba_control_panel.config import DeviceProfile, Mode from ameba_control_panel import theme from ameba_control_panel.controllers.device_tab_controller import DeviceTabController from ameba_control_panel.services.session_store import SessionStore from ameba_control_panel.services.settings_service import Settings from ameba_control_panel.views.settings_dialog import SettingsDialog class MainWindow(QMainWindow): def __init__(self) -> None: super().__init__() self.setWindowTitle(f"{config.APP_NAME} v{config.APP_VERSION}") self._tabs = QTabWidget() self._tabs.setTabsClosable(True) self._tabs.tabCloseRequested.connect(self._close_tab) self._tabs.tabBarDoubleClicked.connect(self._rename_tab) self.setCentralWidget(self._tabs) self._session = SessionStore() self._settings = Settings() self._dut_controllers: list[DeviceTabController] = [] self._next_dut_num = 1 self._settings_applied = False add_btn = QToolButton() add_btn.setText("+") add_btn.setToolTip("Add DUT tab (Ctrl+N)") add_btn.clicked.connect(self._add_dut_tab_auto) self._tabs.setCornerWidget(add_btn, Qt.TopLeftCorner) self._current_palette = theme.LIGHT self._build_menu_bar() self._setup_shortcuts() tab_list = self._session.get_tab_list() if tab_list: for entry in tab_list: key = entry.get("key", "") label = entry.get("label", "") if key and label: self._add_dut_tab(key, label) else: 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: mb = self.menuBar() view_menu = mb.addMenu("&View") view_menu.addAction("New Tab\tCtrl+N", self._add_dut_tab_auto) view_menu.addAction("Close Tab\tCtrl+W", self._close_current_tab) view_menu.addSeparator() view_menu.addAction("Clear Log\tCtrl+Q", self._clear_current_log) view_menu.addAction("Find\tCtrl+S", self._focus_find) settings_menu = mb.addMenu("&Settings") settings_menu.addAction("Preferences...", self._open_settings) help_menu = mb.addMenu("&Help") help_menu.addAction("About", self._show_about) help_menu.addAction("Shortcuts", self._show_shortcuts) def _toggle_theme(self) -> None: if self._current_palette.name == "light": self._current_palette = theme.DARK else: self._current_palette = theme.LIGHT QApplication.instance().setStyleSheet(theme.build_stylesheet(self._current_palette)) p = self._current_palette for ctrl in self._dut_controllers: 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: QMessageBox.about(self, config.APP_NAME, f"{config.APP_NAME}\n" f"Version: {config.APP_VERSION}\n\n" f"Author: Yiek Heng\n" f"Email: wongyiekheng@realtek-sg.com\n\n" f"Licensed under the Apache License, Version 2.0\n" f"All rights reserved. For internal use only.") _KEY_EVENTS = frozenset({QEvent.KeyPress, QEvent.ShortcutOverride}) def _setup_shortcuts(self) -> None: bindings = ( ("N", "New DUT tab", Qt.Key_N, self._add_dut_tab_auto), ("W", "Close current tab", Qt.Key_W, self._close_current_tab), ("D", "Connect / Disconnect", Qt.Key_D, self._toggle_connect), ("S", "Find in log", Qt.Key_S, self._focus_find), ("Q", "Clear log", Qt.Key_Q, self._clear_current_log), ("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: self._close_tab(self._tabs.currentIndex()) def _focus_find(self) -> None: ctrl = self._current_controller() if ctrl: ctrl.view.find_input.setFocus() ctrl.view.find_input.selectAll() def _clear_current_log(self) -> None: ctrl = self._current_controller() if ctrl: ctrl.log.clear() def _send_current(self) -> None: ctrl = self._current_controller() if ctrl: ctrl._send_from_input() def _open_settings(self) -> None: dlg = SettingsDialog(self._settings, self) dlg.set_apply_callback(self._apply_settings) dlg.exec() self._apply_settings() def _apply_settings(self) -> None: font = QFont(self._settings.font_family) font.setStyleHint(QFont.Monospace) font.setPointSize(self._settings.font_size) for ctrl in self._dut_controllers: ctrl.view.log_view.setFont(font) ctrl._port_timer.setInterval(self._settings.port_scan_interval_sec * 1000) def _flash_current(self) -> None: ctrl = self._current_controller() if ctrl: ctrl.flash.run_flash() def _reset_current(self) -> None: ctrl = self._current_controller() if ctrl: ctrl.flash.run_mode(Mode.NORMAL) def _current_controller(self) -> DeviceTabController | None: widget = self._tabs.currentWidget() for ctrl in self._dut_controllers: if ctrl.view is widget: return ctrl return None def _add_dut_tab(self, key: str, label: str) -> DeviceTabController: profile = DeviceProfile(key=key, label=label) controller = DeviceTabController(profile) self._dut_controllers.append(controller) # Wire sidebar Settings/Theme buttons controller.view.settings_btn.clicked.connect(self._open_settings) controller.view.theme_btn.clicked.connect(self._toggle_theme) self._tabs.addTab(controller.view, label) try: num = int(key.split("_")[-1]) if num >= self._next_dut_num: self._next_dut_num = num + 1 except (ValueError, IndexError): pass self._persist_tab_list() return controller def _add_dut_tab_auto(self) -> None: key = f"dut_{self._next_dut_num}" label = f"DUT {self._next_dut_num}" self._add_dut_tab(key, label) self._tabs.setCurrentIndex(self._tabs.count() - 1) def _rename_tab(self, index: int) -> None: old_label = self._tabs.tabText(index) new_label, ok = QInputDialog.getText(self, "Rename Tab", "Tab name:", text=old_label) if not ok or not new_label.strip(): return new_label = new_label.strip() self._tabs.setTabText(index, new_label) for ctrl in self._dut_controllers: if ctrl.view is self._tabs.widget(index): ctrl.profile = DeviceProfile(key=ctrl.profile.key, label=new_label) break self._persist_tab_list() def _close_tab(self, index: int) -> None: if len(self._dut_controllers) <= 1: QMessageBox.information(self, "Close Tab", "Cannot close the last DUT tab.") return widget = self._tabs.widget(index) for ctrl in self._dut_controllers: if ctrl.view is widget: ctrl.shutdown() self._session.remove(ctrl.profile.key) self._dut_controllers.remove(ctrl) break self._tabs.removeTab(index) self._persist_tab_list() def _persist_tab_list(self) -> None: tabs = [{"key": c.profile.key, "label": c.profile.label} for c in self._dut_controllers] self._session.set_tab_list(tabs) def closeEvent(self, event) -> None: # noqa: N802 for c in self._dut_controllers: c.shutdown() self._session.save_now() super().closeEvent(event) def _setup_logging() -> None: log_dir = config.app_data_dir() log_dir.mkdir(parents=True, exist_ok=True) handler = logging.handlers.RotatingFileHandler( log_dir / "app.log", maxBytes=2_000_000, backupCount=3, encoding="utf-8", ) handler.setFormatter(logging.Formatter( "%(asctime)s %(name)s %(levelname)s %(message)s", datefmt="%Y-%m-%d %H:%M:%S", )) root = logging.getLogger() root.setLevel(logging.DEBUG) root.addHandler(handler) logging.getLogger(__name__).info("App started v%s", config.APP_VERSION) def _setup_linux_platform() -> None: """Ensure correct Qt platform plugin on Linux.""" import os if sys.platform != "linux": return # Disable xdg-desktop-portal and GVfs UDisks2 volume monitor to prevent # file dialog crashes / hangs caused by D-Bus service timeouts. os.environ.setdefault("QT_DISABLE_XDG_DESKTOP_PORTAL", "1") os.environ.setdefault("GIO_USE_VOLUME_MONITOR", "unix") if os.environ.get("QT_QPA_PLATFORM"): return if os.environ.get("WAYLAND_DISPLAY"): os.environ["QT_QPA_PLATFORM"] = "wayland;xcb" elif os.environ.get("DISPLAY"): os.environ["QT_QPA_PLATFORM"] = "xcb" def main() -> None: _setup_linux_platform() _setup_logging() QApplication.setStyle("Fusion") app = QApplication(sys.argv) app.setStyleSheet(theme.build_stylesheet(theme.LIGHT)) window = MainWindow() window.resize(1200, 800) window.show() sys.exit(app.exec()) if __name__ == "__main__": main()