from __future__ import annotations import logging import logging.handlers import sys from PySide6.QtCore import Qt from PySide6.QtGui import QFont, QKeySequence, QShortcut from PySide6.QtWidgets import ( QApplication, QInputDialog, QMainWindow, QMenuBar, QMessageBox, QTabWidget, QToolButton, ) from ameba_control_panel import config from ameba_control_panel.config import DeviceProfile 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 add_btn = QToolButton() add_btn.setText("+") add_btn.setToolTip("Add DUT tab (Ctrl+T)") 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 _build_menu_bar(self) -> None: mb = self.menuBar() view_menu = mb.addMenu("&View") view_menu.addAction("New Tab", self._add_dut_tab_auto, QKeySequence("Ctrl+T")) view_menu.addAction("Close Tab", self._close_current_tab, QKeySequence("Ctrl+W")) view_menu.addSeparator() view_menu.addAction("Clear Log", self._clear_current_log, QKeySequence("Ctrl+L")) view_menu.addAction("Find", self._focus_find, QKeySequence("Ctrl+F")) settings_menu = mb.addMenu("&Settings") settings_menu.addAction("Preferences...", self._open_settings) help_menu = mb.addMenu("&Help") help_menu.addAction("About", self._show_about) 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 _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.") def _setup_shortcuts(self) -> None: QShortcut(QKeySequence("Ctrl+T"), self, activated=self._add_dut_tab_auto) QShortcut(QKeySequence("Ctrl+W"), self, activated=self._close_current_tab) QShortcut(QKeySequence("Ctrl+F"), self, activated=self._focus_find) QShortcut(QKeySequence("Ctrl+L"), self, activated=self._clear_current_log) QShortcut(QKeySequence("Ctrl+Return"), self, activated=self._send_current) QShortcut(QKeySequence("Ctrl+Shift+F"), self, activated=self._flash_current) QShortcut(QKeySequence("Ctrl+R"), self, activated=self._reset_current) 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: from ameba_control_panel.config import Mode ctrl.flash.run_mode(Mode.RESET) 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" or 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()