From 2a00aa9a92790129d14b7b71a8afc4faa5b08078 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 2 Jul 2026 15:32:09 +0800 Subject: [PATCH 1/7] Redesign GUI around a menu-driven, low-button layout Move per-tab commands from in-tab buttons into a dynamic window-level Actions menu so tabs stay minimal (inputs and results only). Core tabs declare their actions at registration; feature tabs expose them via a menu_actions() hook. Convert core tabs plus hotkeys, variables, secrets, recording editor, and flow editor as the first batch. --- je_auto_control/gui/_auto_click_tab.py | 26 +-- je_auto_control/gui/_report_tab.py | 28 +-- je_auto_control/gui/flow_editor/tab.py | 36 ++-- je_auto_control/gui/hotkeys_tab.py | 26 ++- .../gui/language_wrapper/english.py | 4 + .../gui/language_wrapper/japanese.py | 4 + .../language_wrapper/simplified_chinese.py | 4 + .../language_wrapper/traditional_chinese.py | 4 + je_auto_control/gui/main_widget.py | 168 +++++++++--------- je_auto_control/gui/main_window.py | 28 +++ je_auto_control/gui/recording_editor_tab.py | 66 +++---- je_auto_control/gui/secrets_tab.py | 37 ++-- je_auto_control/gui/variables_tab.py | 28 ++- 13 files changed, 219 insertions(+), 240 deletions(-) diff --git a/je_auto_control/gui/_auto_click_tab.py b/je_auto_control/gui/_auto_click_tab.py index 684732d1..17d7d5a0 100644 --- a/je_auto_control/gui/_auto_click_tab.py +++ b/je_auto_control/gui/_auto_click_tab.py @@ -1,6 +1,6 @@ from PySide6.QtGui import QIntValidator from PySide6.QtWidgets import ( - QWidget, QLineEdit, QComboBox, QPushButton, QVBoxLayout, QLabel, + QWidget, QLineEdit, QComboBox, QVBoxLayout, QLabel, QGridLayout, QHBoxLayout, QRadioButton, QButtonGroup, QMessageBox, QGroupBox, ) @@ -100,29 +100,18 @@ def _build_auto_click_tab(self) -> QWidget: rh.addWidget(self.repeat_count_input) grid.addLayout(rh, row, 0, 1, 2) - row += 1 - btn_h = QHBoxLayout() - self.start_button = self._tr(QPushButton(), "start") - self.start_button.clicked.connect(self._start_auto_click) - self.stop_button = self._tr(QPushButton(), "stop") - self.stop_button.clicked.connect(self._stop_auto_click) - btn_h.addWidget(self.start_button) - btn_h.addWidget(self.stop_button) - grid.addLayout(btn_h, row, 0, 1, 2) - click_group.setLayout(grid) outer.addWidget(click_group) + # Start/stop, position probe, hotkey, write, and scroll commands all + # run from the Actions menu; the tab keeps only their inputs. pos_group = self._tr(QGroupBox(), "get_position") pos_layout = QHBoxLayout() - self.pos_btn = self._tr(QPushButton(), "get_position") - self.pos_btn.clicked.connect(self._get_mouse_pos) self.pos_label = QLabel() self._pos_label_suffix = " --" self.pos_label.setText( self._translate("current_position") + self._pos_label_suffix, ) - pos_layout.addWidget(self.pos_btn) pos_layout.addWidget(self.pos_label) pos_group.setLayout(pos_layout) outer.addWidget(pos_group) @@ -131,20 +120,14 @@ def _build_auto_click_tab(self) -> QWidget: hk_layout = QHBoxLayout() self.hotkey_input = QLineEdit() self.hotkey_input.setPlaceholderText("ctrl,a") - self.hotkey_btn = self._tr(QPushButton(), "hotkey_send") - self.hotkey_btn.clicked.connect(self._send_hotkey) hk_layout.addWidget(self.hotkey_input) - hk_layout.addWidget(self.hotkey_btn) hotkey_group.setLayout(hk_layout) outer.addWidget(hotkey_group) write_group = self._tr(QGroupBox(), "write_label") wr_layout = QHBoxLayout() self.write_input = QLineEdit() - self.write_btn = self._tr(QPushButton(), "write_send") - self.write_btn.clicked.connect(self._send_write) wr_layout.addWidget(self.write_input) - wr_layout.addWidget(self.write_btn) write_group.setLayout(wr_layout) outer.addWidget(write_group) @@ -160,9 +143,6 @@ def _build_auto_click_tab(self) -> QWidget: sc_layout.addWidget(self.scroll_dir_combo) else: self.scroll_dir_combo = None - self.scroll_btn = self._tr(QPushButton(), "scroll_send") - self.scroll_btn.clicked.connect(self._send_scroll) - sc_layout.addWidget(self.scroll_btn) scroll_group.setLayout(sc_layout) outer.addWidget(scroll_group) diff --git a/je_auto_control/gui/_report_tab.py b/je_auto_control/gui/_report_tab.py index f598d30c..f3049e7c 100644 --- a/je_auto_control/gui/_report_tab.py +++ b/je_auto_control/gui/_report_tab.py @@ -1,6 +1,6 @@ """Report-generation tab builder (extracted mixin).""" from PySide6.QtWidgets import ( - QGroupBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, QTextEdit, QVBoxLayout, QWidget, ) @@ -21,15 +21,11 @@ def _build_report_tab(self) -> QWidget: tab = QWidget() layout = QVBoxLayout() + # Enable/disable recording and report generation run from the + # Actions menu; the tab keeps only status, name input, and result. tr_group = self._tr(QGroupBox(), "test_record_status") tr_h = QHBoxLayout() - self.tr_enable_btn = self._tr(QPushButton(), "enable_test_record") - self.tr_enable_btn.clicked.connect(lambda: self._set_test_record(True)) - self.tr_disable_btn = self._tr(QPushButton(), "disable_test_record") - self.tr_disable_btn.clicked.connect(lambda: self._set_test_record(False)) self.tr_status_label = QLabel("OFF") - tr_h.addWidget(self.tr_enable_btn) - tr_h.addWidget(self.tr_disable_btn) tr_h.addWidget(self.tr_status_label) tr_group.setLayout(tr_h) layout.addWidget(tr_group) @@ -40,18 +36,6 @@ def _build_report_tab(self) -> QWidget: name_h.addWidget(self.report_name_input) layout.addLayout(name_h) - btn_h = QHBoxLayout() - self.html_report_btn = self._tr(QPushButton(), "generate_html_report") - self.html_report_btn.clicked.connect(self._gen_html) - self.json_report_btn = self._tr(QPushButton(), "generate_json_report") - self.json_report_btn.clicked.connect(self._gen_json) - self.xml_report_btn = self._tr(QPushButton(), "generate_xml_report") - self.xml_report_btn.clicked.connect(self._gen_xml) - btn_h.addWidget(self.html_report_btn) - btn_h.addWidget(self.json_report_btn) - btn_h.addWidget(self.xml_report_btn) - layout.addLayout(btn_h) - layout.addWidget(self._tr(QLabel(), "report_result")) self.report_result_text = QTextEdit() self.report_result_text.setReadOnly(True) @@ -64,6 +48,12 @@ def _set_test_record(self, enable: bool): test_record_instance.set_record_enable(enable) self.tr_status_label.setText("ON" if enable else "OFF") + def _enable_test_record(self): + self._set_test_record(True) + + def _disable_test_record(self): + self._set_test_record(False) + def _gen_html(self): try: name = self.report_name_input.text() or "autocontrol_report" diff --git a/je_auto_control/gui/flow_editor/tab.py b/je_auto_control/gui/flow_editor/tab.py index 96eb5cd6..248a995d 100644 --- a/je_auto_control/gui/flow_editor/tab.py +++ b/je_auto_control/gui/flow_editor/tab.py @@ -10,8 +10,8 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QFileDialog, QGraphicsView, QHBoxLayout, QLabel, QMessageBox, - QPushButton, QSplitter, QTextEdit, QVBoxLayout, QWidget, + QFileDialog, QGraphicsView, QLabel, QMessageBox, + QSplitter, QTextEdit, QVBoxLayout, QWidget, ) from je_auto_control.gui._i18n_helpers import TranslatableMixin @@ -50,21 +50,9 @@ def retranslate(self) -> None: self._apply_translations() def _build_layout(self) -> None: + # Open/save/zoom/fit commands run from the Actions menu; the tab + # keeps only the graph view, the inspector, and the status line. root = QVBoxLayout(self) - toolbar = QHBoxLayout() - for key, slot in ( - ("flow_open_btn", self._on_open), - ("flow_save_btn", self._on_save), - ("flow_zoom_in_btn", self._on_zoom_in), - ("flow_zoom_out_btn", self._on_zoom_out), - ("flow_fit_btn", self._on_fit), - ): - btn = QPushButton() - btn.setObjectName(key) - btn.clicked.connect(slot) - toolbar.addWidget(btn) - toolbar.addStretch() - root.addLayout(toolbar) splitter = QSplitter(Qt.Horizontal) splitter.addWidget(self._view) splitter.addWidget(self._inspector) @@ -74,13 +62,17 @@ def _build_layout(self) -> None: root.addWidget(self._status) self._apply_translations() + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("flow_open_btn", self._on_open), + ("flow_save_btn", self._on_save), + ("flow_zoom_in_btn", self._on_zoom_in), + ("flow_zoom_out_btn", self._on_zoom_out), + ("flow_fit_btn", self._on_fit), + ] + def _apply_translations(self) -> None: - for key in ("flow_open_btn", "flow_save_btn", - "flow_zoom_in_btn", "flow_zoom_out_btn", - "flow_fit_btn"): - widget = self.findChild(QPushButton, key) - if widget is not None: - widget.setText(_t(key)) self._inspector.setPlaceholderText(_t("flow_inspector_placeholder")) # --- public ---------------------------------------------------- diff --git a/je_auto_control/gui/hotkeys_tab.py b/je_auto_control/gui/hotkeys_tab.py index 161039a7..c301b52a 100644 --- a/je_auto_control/gui/hotkeys_tab.py +++ b/je_auto_control/gui/hotkeys_tab.py @@ -3,7 +3,7 @@ from PySide6.QtCore import QTimer from PySide6.QtWidgets import ( - QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, + QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -38,34 +38,28 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._build_layout() def _build_layout(self) -> None: + # Bind/remove/start/stop commands run from the Actions menu; the + # tab keeps only the inputs, the bindings table, and the status. root = QVBoxLayout(self) form = QHBoxLayout() form.addWidget(self._tr(QLabel(), "hk_combo_label")) form.addWidget(self._combo_input) form.addWidget(self._tr(QLabel(), "hk_script_label")) form.addWidget(self._script_input, stretch=1) - browse = self._tr(QPushButton(), "browse") - browse.clicked.connect(self._browse) - form.addWidget(browse) - add = self._tr(QPushButton(), "hk_bind") - add.clicked.connect(self._on_bind) - form.addWidget(add) root.addLayout(form) root.addWidget(self._table, stretch=1) + root.addWidget(self._status) - ctl = QHBoxLayout() - for key, handler in ( + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("browse", self._browse), + ("hk_bind", self._on_bind), ("hk_remove_selected", self._on_remove), ("hk_start_daemon", self._on_start), ("hk_stop_daemon", self._on_stop), - ): - btn = self._tr(QPushButton(), key) - btn.clicked.connect(handler) - ctl.addWidget(btn) - ctl.addStretch() - root.addLayout(ctl) - root.addWidget(self._status) + ] def _apply_status_label(self) -> None: key = "hk_daemon_running" if self._daemon_running else "hk_daemon_stopped" diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index 66da26d2..bfc915ed 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -1308,4 +1308,8 @@ "menu_language": "Language", "menu_help": "Help", "menu_help_about": "About AutoControlGUI", + "menu_actions": "Actions", + "menu_actions_none": "(No actions on this tab)", + "menu_choose_script_dir": "Choose Script Directory...", + "execute_editor_script": "Run Editor Content", } diff --git a/je_auto_control/gui/language_wrapper/japanese.py b/je_auto_control/gui/language_wrapper/japanese.py index 83dbfe6b..2b6d5f30 100644 --- a/je_auto_control/gui/language_wrapper/japanese.py +++ b/je_auto_control/gui/language_wrapper/japanese.py @@ -1195,4 +1195,8 @@ "menu_language": "言語", "menu_help": "ヘルプ", "menu_help_about": "AutoControlGUI について", + "menu_actions": "アクション", + "menu_actions_none": "(このタブにアクションはありません)", + "menu_choose_script_dir": "スクリプトフォルダーを選択...", + "execute_editor_script": "エディター内容を実行", } diff --git a/je_auto_control/gui/language_wrapper/simplified_chinese.py b/je_auto_control/gui/language_wrapper/simplified_chinese.py index b4bb4930..b58b62b4 100644 --- a/je_auto_control/gui/language_wrapper/simplified_chinese.py +++ b/je_auto_control/gui/language_wrapper/simplified_chinese.py @@ -1180,4 +1180,8 @@ "menu_language": "语言", "menu_help": "帮助", "menu_help_about": "关于 AutoControlGUI", + "menu_actions": "操作", + "menu_actions_none": "(此标签页没有操作)", + "menu_choose_script_dir": "选择脚本目录...", + "execute_editor_script": "运行编辑器内容", } diff --git a/je_auto_control/gui/language_wrapper/traditional_chinese.py b/je_auto_control/gui/language_wrapper/traditional_chinese.py index ca6012c8..f948a91d 100644 --- a/je_auto_control/gui/language_wrapper/traditional_chinese.py +++ b/je_auto_control/gui/language_wrapper/traditional_chinese.py @@ -1181,4 +1181,8 @@ "menu_language": "語言", "menu_help": "說明", "menu_help_about": "關於 AutoControlGUI", + "menu_actions": "操作", + "menu_actions_none": "(此分頁沒有操作)", + "menu_choose_script_dir": "選擇腳本資料夾...", + "execute_editor_script": "執行編輯器內容", } diff --git a/je_auto_control/gui/main_widget.py b/je_auto_control/gui/main_widget.py index 73497a1f..a0158c4c 100644 --- a/je_auto_control/gui/main_widget.py +++ b/je_auto_control/gui/main_widget.py @@ -5,7 +5,7 @@ from PySide6.QtCore import QTimer, Signal, QObject from PySide6.QtGui import QIntValidator, QDoubleValidator, QKeyEvent, Qt from PySide6.QtWidgets import ( - QWidget, QLineEdit, QPushButton, QVBoxLayout, QLabel, + QWidget, QLineEdit, QVBoxLayout, QLabel, QGridLayout, QHBoxLayout, QMessageBox, QTabWidget, QTextEdit, QFileDialog, QCheckBox, QGroupBox ) @@ -94,6 +94,7 @@ class _TabEntry: widget: QWidget category: str = "core" default_visible: bool = False + actions: tuple = () # ============================================================================= @@ -105,6 +106,7 @@ class AutoControlGUIWidget( """Owns the QTabWidget and exposes show/hide/list APIs for the menu bar.""" tabs_changed = Signal() + current_tab_changed = Signal() def __init__(self, parent=None): super().__init__(parent) @@ -123,19 +125,50 @@ def __init__(self, parent=None): # core tabs (auto_click / screenshot / image_detect) are still # registered and reachable from the View menu's "show tab" list. self._add_tab("auto_click", "tab_auto_click", self._build_auto_click_tab(), - category="core") + category="core", actions=( + ("start", self._start_auto_click), + ("stop", self._stop_auto_click), + ("get_position", self._get_mouse_pos), + ("hotkey_send", self._send_hotkey), + ("write_send", self._send_write), + ("scroll_send", self._send_scroll), + )) self._add_tab("screenshot", "tab_screenshot", self._build_screenshot_tab(), - category="core") + category="core", actions=( + ("take_screenshot", self._take_screenshot), + ("browse", self._browse_ss_path), + ("pick_region", self._pick_ss_region), + ("get_screen_size", self._get_screen_size), + ("get_pixel_label", self._get_pixel_color), + )) self._add_tab("image_detect", "tab_image_detect", self._build_image_detect_tab(), - category="core") + category="core", actions=( + ("browse", self._browse_img), + ("crop_template", self._crop_template), + ("locate_image", self._locate_image), + ("locate_all", self._locate_all), + ("locate_click", self._locate_click), + )) self._add_tab("record", "tab_record", self._build_record_tab(), - category="core", default_visible=True) + category="core", default_visible=True, actions=( + ("start_record", self._start_record), + ("stop_record", self._stop_record), + ("playback", self._playback_record), + ("save_record", self._save_record), + ("load_record", self._load_record), + )) self._add_tab("script_builder", "tab_script_builder", ScriptBuilderTab(), category="core", default_visible=True) self._add_tab("flow_editor", "tab_flow_editor", FlowEditorTab(), category="editing") self._add_tab("script", "tab_script", self._build_script_tab(), - category="editing") + category="editing", actions=( + ("load_script", self._browse_script), + ("execute_script", self._execute_script), + ("menu_choose_script_dir", self._browse_script_dir), + ("execute_dir", self._execute_dir), + ("execute_editor_script", self._execute_manual_script), + )) self._add_tab("recording_editor", "tab_recording_editor", RecordingEditorTab(), category="editing") self._add_tab("variables", "tab_variables", VariablesTab(), @@ -220,11 +253,19 @@ def __init__(self, parent=None): self._add_tab("diagnostics", "tab_diagnostics", DiagnosticsTab(), category="system") self._add_tab("report", "tab_report", self._build_report_tab(), - category="system") + category="system", actions=( + ("enable_test_record", self._enable_test_record), + ("disable_test_record", self._disable_test_record), + ("generate_html_report", self._gen_html), + ("generate_json_report", self._gen_json), + ("generate_xml_report", self._gen_xml), + )) layout.addWidget(self.tabs) self.setLayout(layout) + self.tabs.currentChanged.connect(self._on_current_tab_changed) + self.timer = QTimer() self.repeat_count = 0 self.repeat_max = 0 @@ -255,14 +296,40 @@ def _build_remote_desktop_tab() -> QWidget: def _add_tab( self, key: str, title_key: str, widget: QWidget, category: str = "core", default_visible: bool = False, + actions: tuple = (), ) -> None: self._tab_entries.append(_TabEntry( key=key, title_key=title_key, widget=widget, category=category, default_visible=default_visible, + actions=actions, )) if default_visible: self.tabs.addTab(widget, language_wrapper.translate(title_key, title_key)) + def _on_current_tab_changed(self, _index: int) -> None: + self.current_tab_changed.emit() + + def current_tab_menu_actions(self) -> list: + """Return ``[(label_key, callable), ...]`` for the active tab. + + Core tabs declare their actions at registration time; feature tabs + may instead expose a ``menu_actions()`` method returning the same + shape. The menu bar renders these under the Actions menu so tabs + stay button-free. + """ + widget = self.tabs.currentWidget() + if widget is None: + return [] + for entry in self._tab_entries: + if entry.widget is widget: + if entry.actions: + return list(entry.actions) + provider = getattr(widget, "menu_actions", None) + if callable(provider): + return list(provider()) + return [] + return [] + def _find_entry(self, key: str): for entry in self._tab_entries: if entry.key == key: @@ -363,44 +430,29 @@ def _build_screenshot_tab(self) -> QWidget: tab = QWidget() layout = QVBoxLayout() - # Screen size + # Screen size (read via Actions menu -> Get Screen Size) size_group = self._tr(QGroupBox(), "screen_size_label") sg = QHBoxLayout() self.screen_size_label = QLabel("--") - self.screen_size_btn = self._tr(QPushButton(), "get_screen_size") - self.screen_size_btn.clicked.connect(self._get_screen_size) sg.addWidget(self.screen_size_label) - sg.addWidget(self.screen_size_btn) size_group.setLayout(sg) layout.addWidget(size_group) - # Screenshot + # Screenshot inputs; capture runs from the Actions menu. ss_group = self._tr(QGroupBox(), "take_screenshot") ss_grid = QGridLayout() ss_grid.addWidget(self._tr(QLabel(), "file_path_label"), 0, 0) self.ss_path_input = QLineEdit() ss_grid.addWidget(self.ss_path_input, 0, 1) - self.ss_browse_btn = self._tr(QPushButton(), "browse") - self.ss_browse_btn.clicked.connect(self._browse_ss_path) - ss_grid.addWidget(self.ss_browse_btn, 0, 2) ss_grid.addWidget(self._tr(QLabel(), "region_label"), 1, 0) self.ss_region_input = QLineEdit() self.ss_region_input.setPlaceholderText("0, 0, 800, 600") ss_grid.addWidget(self.ss_region_input, 1, 1) - self.ss_pick_region_btn = self._tr(QPushButton(), "pick_region") - self.ss_pick_region_btn.clicked.connect(self._pick_ss_region) - ss_grid.addWidget(self.ss_pick_region_btn, 1, 2) - - btn_h = QHBoxLayout() - self.ss_take_btn = self._tr(QPushButton(), "take_screenshot") - self.ss_take_btn.clicked.connect(self._take_screenshot) - btn_h.addWidget(self.ss_take_btn) - ss_grid.addLayout(btn_h, 2, 0, 1, 3) ss_group.setLayout(ss_grid) layout.addWidget(ss_group) - # Get pixel + # Pixel probe inputs; lookup runs from the Actions menu. px_group = self._tr(QGroupBox(), "get_pixel_label") px_grid = QGridLayout() px_grid.addWidget(self._tr(QLabel(), "pixel_x"), 0, 0) @@ -411,15 +463,12 @@ def _build_screenshot_tab(self) -> QWidget: self.pixel_y_input = QLineEdit("0") self.pixel_y_input.setValidator(QIntValidator()) px_grid.addWidget(self.pixel_y_input, 0, 3) - self.pixel_btn = self._tr(QPushButton(), "get_pixel_label") - self.pixel_btn.clicked.connect(self._get_pixel_color) - px_grid.addWidget(self.pixel_btn, 1, 0, 1, 2) self.pixel_result_label = QLabel() self._pixel_result_suffix = " --" self.pixel_result_label.setText( self._translate("pixel_result") + self._pixel_result_suffix, ) - px_grid.addWidget(self.pixel_result_label, 1, 2, 1, 2) + px_grid.addWidget(self.pixel_result_label, 1, 0, 1, 4) px_group.setLayout(px_grid) layout.addWidget(px_group) @@ -487,16 +536,11 @@ def _build_image_detect_tab(self) -> QWidget: tab = QWidget() layout = QVBoxLayout() + # Detection inputs; locate/crop commands run from the Actions menu. grid = QGridLayout() grid.addWidget(self._tr(QLabel(), "template_image"), 0, 0) self.img_path_input = QLineEdit() grid.addWidget(self.img_path_input, 0, 1) - self.img_browse_btn = self._tr(QPushButton(), "browse") - self.img_browse_btn.clicked.connect(self._browse_img) - grid.addWidget(self.img_browse_btn, 0, 2) - self.img_crop_btn = self._tr(QPushButton(), "crop_template") - self.img_crop_btn.clicked.connect(self._crop_template) - grid.addWidget(self.img_crop_btn, 0, 3) grid.addWidget(self._tr(QLabel(), "threshold_label"), 1, 0) self.threshold_input = QLineEdit("0.8") @@ -507,18 +551,6 @@ def _build_image_detect_tab(self) -> QWidget: layout.addLayout(grid) - btn_h = QHBoxLayout() - self.locate_btn = self._tr(QPushButton(), "locate_image") - self.locate_btn.clicked.connect(self._locate_image) - self.locate_all_btn = self._tr(QPushButton(), "locate_all") - self.locate_all_btn.clicked.connect(self._locate_all) - self.locate_click_btn = self._tr(QPushButton(), "locate_click") - self.locate_click_btn.clicked.connect(self._locate_click) - btn_h.addWidget(self.locate_btn) - btn_h.addWidget(self.locate_all_btn) - btn_h.addWidget(self.locate_click_btn) - layout.addLayout(btn_h) - layout.addWidget(self._tr(QLabel(), "detection_result")) self.detect_result_text = QTextEdit() self.detect_result_text.setReadOnly(True) @@ -586,32 +618,12 @@ def _build_record_tab(self) -> QWidget: tab = QWidget() layout = QVBoxLayout() + # Record/playback/save/load all run from the Actions menu. self._record_status_key = "record_idle" self.record_status_label = QLabel() self._apply_record_status_label() layout.addWidget(self.record_status_label) - btn_h = QHBoxLayout() - self.rec_start_btn = self._tr(QPushButton(), "start_record") - self.rec_start_btn.clicked.connect(self._start_record) - self.rec_stop_btn = self._tr(QPushButton(), "stop_record") - self.rec_stop_btn.clicked.connect(self._stop_record) - self.rec_play_btn = self._tr(QPushButton(), "playback") - self.rec_play_btn.clicked.connect(self._playback_record) - btn_h.addWidget(self.rec_start_btn) - btn_h.addWidget(self.rec_stop_btn) - btn_h.addWidget(self.rec_play_btn) - layout.addLayout(btn_h) - - btn_h2 = QHBoxLayout() - self.rec_save_btn = self._tr(QPushButton(), "save_record") - self.rec_save_btn.clicked.connect(self._save_record) - self.rec_load_btn = self._tr(QPushButton(), "load_record") - self.rec_load_btn.clicked.connect(self._load_record) - btn_h2.addWidget(self.rec_save_btn) - btn_h2.addWidget(self.rec_load_btn) - layout.addLayout(btn_h2) - layout.addWidget(self._tr(QLabel(), "record_list_label")) self.record_list_text = QTextEdit() self.record_list_text.setReadOnly(True) @@ -682,26 +694,18 @@ def _build_script_tab(self) -> QWidget: tab = QWidget() layout = QVBoxLayout() + # Load/execute commands run from the Actions menu; the tab keeps + # only the path inputs, the editor, and the result view. file_h = QHBoxLayout() + file_h.addWidget(self._tr(QLabel(), "file_path_label")) self.script_path_input = QLineEdit() - self.script_browse_btn = self._tr(QPushButton(), "load_script") - self.script_browse_btn.clicked.connect(self._browse_script) - self.script_exec_btn = self._tr(QPushButton(), "execute_script") - self.script_exec_btn.clicked.connect(self._execute_script) file_h.addWidget(self.script_path_input) - file_h.addWidget(self.script_browse_btn) - file_h.addWidget(self.script_exec_btn) layout.addLayout(file_h) dir_h = QHBoxLayout() + dir_h.addWidget(self._tr(QLabel(), "execute_dir_label")) self.script_dir_input = QLineEdit() - self.script_dir_browse_btn = self._tr(QPushButton(), "execute_dir_label") - self.script_dir_browse_btn.clicked.connect(self._browse_script_dir) - self.script_dir_exec_btn = self._tr(QPushButton(), "execute_dir") - self.script_dir_exec_btn.clicked.connect(self._execute_dir) dir_h.addWidget(self.script_dir_input) - dir_h.addWidget(self.script_dir_browse_btn) - dir_h.addWidget(self.script_dir_exec_btn) layout.addLayout(dir_h) layout.addWidget(self._tr(QLabel(), "script_content")) @@ -709,10 +713,6 @@ def _build_script_tab(self) -> QWidget: self.script_editor.setPlaceholderText('[["AC_type_keyboard", {"keycode": "a"}]]') layout.addWidget(self.script_editor) - exec_btn = self._tr(QPushButton(), "execute_script") - exec_btn.clicked.connect(self._execute_manual_script) - layout.addWidget(exec_btn) - layout.addWidget(self._tr(QLabel(), "execution_result")) self.script_result_text = QTextEdit() self.script_result_text.setReadOnly(True) diff --git a/je_auto_control/gui/main_window.py b/je_auto_control/gui/main_window.py index b228649d..b99e7877 100644 --- a/je_auto_control/gui/main_window.py +++ b/je_auto_control/gui/main_window.py @@ -56,9 +56,14 @@ def __init__(self) -> None: self.setCentralWidget(self.auto_control_gui_widget) self._view_menu: QMenu = None + self._actions_menu: QMenu = None self._tab_actions: list = [] self._build_menu_bar() self.auto_control_gui_widget.tabs_changed.connect(self._rebuild_tabs_menu) + self.auto_control_gui_widget.tabs_changed.connect(self._rebuild_actions_menu) + self.auto_control_gui_widget.current_tab_changed.connect( + self._rebuild_actions_menu, + ) language_wrapper.add_listener(self._on_language_changed) # --- menu construction --------------------------------------------------- @@ -67,11 +72,34 @@ def _build_menu_bar(self) -> None: bar = self.menuBar() bar.clear() bar.addMenu(self._build_file_menu()) + bar.addMenu(self._build_actions_menu()) bar.addMenu(self._build_view_menu()) bar.addMenu(self._build_tools_menu()) bar.addMenu(self._build_language_menu()) bar.addMenu(self._build_help_menu()) + def _build_actions_menu(self) -> QMenu: + """Per-tab command menu: the active tab's operations live here + instead of as buttons inside the tab.""" + self._actions_menu = QMenu(_t("menu_actions", "Actions"), self) + self._rebuild_actions_menu() + return self._actions_menu + + def _rebuild_actions_menu(self) -> None: + if self._actions_menu is None: + return + self._actions_menu.clear() + entries = self.auto_control_gui_widget.current_tab_menu_actions() + if not entries: + placeholder = QAction( + _t("menu_actions_none", "(No actions on this tab)"), self, + ) + placeholder.setEnabled(False) + self._actions_menu.addAction(placeholder) + return + for label_key, handler in entries: + self._actions_menu.addAction(_t(label_key, label_key), handler) + def _build_file_menu(self) -> QMenu: menu = QMenu(_t("menu_file", "File"), self) open_action = QAction(_t("menu_file_open_script", "Open Script..."), self) diff --git a/je_auto_control/gui/recording_editor_tab.py b/je_auto_control/gui/recording_editor_tab.py index 3d325397..b22c9097 100644 --- a/je_auto_control/gui/recording_editor_tab.py +++ b/je_auto_control/gui/recording_editor_tab.py @@ -5,7 +5,7 @@ from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( QFileDialog, QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget, - QMessageBox, QPushButton, QTextEdit, QVBoxLayout, QWidget, + QMessageBox, QTextEdit, QVBoxLayout, QWidget, ) from je_auto_control.gui._i18n_helpers import TranslatableMixin @@ -65,19 +65,12 @@ def _undo(self) -> None: self._refresh() def _build_layout(self) -> None: + # Load/save/export and every edit command run from the Actions + # menu; the tab keeps only inputs, the list, preview, and status. root = QVBoxLayout(self) top = QHBoxLayout() top.addWidget(self._tr(QLabel(), "re_file_label")) top.addWidget(self._path_input, stretch=1) - for key, handler in ( - ("re_browse", self._browse), - ("re_load", self._load), - ("re_save_as", self._save_as), - ("re_export_code", self._export_code), - ): - btn = self._tr(QPushButton(), key) - btn.clicked.connect(handler) - top.addWidget(btn) root.addLayout(top) root.addWidget(self._list, stretch=1) @@ -87,15 +80,6 @@ def _build_layout(self) -> None: ops1.addWidget(self._trim_start) ops1.addWidget(self._tr(QLabel(), "re_trim_end")) ops1.addWidget(self._trim_end) - trim_btn = self._tr(QPushButton(), "re_apply_trim") - trim_btn.clicked.connect(self._apply_trim) - ops1.addWidget(trim_btn) - remove_btn = self._tr(QPushButton(), "re_remove_selected") - remove_btn.clicked.connect(self._remove_selected) - ops1.addWidget(remove_btn) - undo_btn = self._tr(QPushButton(), "re_undo") - undo_btn.clicked.connect(self._undo) - ops1.addWidget(undo_btn) ops1.addStretch() root.addLayout(ops1) @@ -104,36 +88,42 @@ def _build_layout(self) -> None: ops2.addWidget(self._delay_factor) ops2.addWidget(self._tr(QLabel(), "re_floor_ms")) ops2.addWidget(self._delay_clamp) - delay_btn = self._tr(QPushButton(), "re_apply_delays") - delay_btn.clicked.connect(self._apply_delays) - ops2.addWidget(delay_btn) ops2.addWidget(self._tr(QLabel(), "re_scale_x")) ops2.addWidget(self._scale_x) ops2.addWidget(self._tr(QLabel(), "re_scale_y")) ops2.addWidget(self._scale_y) - scale_btn = self._tr(QPushButton(), "re_apply_scale") - scale_btn.clicked.connect(self._apply_scale) - ops2.addWidget(scale_btn) ops2.addStretch() root.addLayout(ops2) - ops3 = QHBoxLayout() - keep_mouse = self._tr(QPushButton(), "re_keep_mouse") - keep_mouse.clicked.connect(lambda: self._filter_prefix("AC_mouse")) - keep_keyboard = self._tr(QPushButton(), "re_keep_keyboard") - keep_keyboard.clicked.connect( - lambda: self._filter_prefix(("AC_type_keyboard", "AC_press_keyboard_key", - "AC_release_keyboard_key", "AC_hotkey", "AC_write")) - ) - ops3.addWidget(keep_mouse) - ops3.addWidget(keep_keyboard) - ops3.addStretch() - root.addLayout(ops3) - root.addWidget(self._tr(QLabel(), "re_preview")) root.addWidget(self._preview, stretch=1) root.addWidget(self._status) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("re_browse", self._browse), + ("re_load", self._load), + ("re_save_as", self._save_as), + ("re_export_code", self._export_code), + ("re_apply_trim", self._apply_trim), + ("re_remove_selected", self._remove_selected), + ("re_undo", self._undo), + ("re_apply_delays", self._apply_delays), + ("re_apply_scale", self._apply_scale), + ("re_keep_mouse", self._keep_mouse), + ("re_keep_keyboard", self._keep_keyboard), + ] + + def _keep_mouse(self) -> None: + self._filter_prefix("AC_mouse") + + def _keep_keyboard(self) -> None: + self._filter_prefix(( + "AC_type_keyboard", "AC_press_keyboard_key", + "AC_release_keyboard_key", "AC_hotkey", "AC_write", + )) + def _browse(self) -> None: path, _ = QFileDialog.getOpenFileName( self, _t("re_dialog_open"), "", "JSON (*.json)", diff --git a/je_auto_control/gui/secrets_tab.py b/je_auto_control/gui/secrets_tab.py index aae1362a..71244ab8 100644 --- a/je_auto_control/gui/secrets_tab.py +++ b/je_auto_control/gui/secrets_tab.py @@ -3,7 +3,7 @@ from PySide6.QtWidgets import ( QAbstractItemView, QGroupBox, QHBoxLayout, QInputDialog, QLabel, - QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPushButton, + QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QVBoxLayout, QWidget, ) @@ -39,42 +39,35 @@ def retranslate(self) -> None: self._refresh_status() def _build_layout(self) -> None: + # Vault commands (init/unlock/lock/add/remove/change passphrase) + # run from the Actions menu; the tab keeps only passphrase input, + # the entry list, and the status line. root = QVBoxLayout(self) unlock_box = self._tr(QGroupBox(), "secret_unlock_group") unlock_layout = QHBoxLayout(unlock_box) unlock_layout.addWidget(self._tr(QLabel(), "secret_passphrase_label")) self._passphrase.setPlaceholderText(_t("secret_passphrase_placeholder")) unlock_layout.addWidget(self._passphrase) - init_btn = self._tr(QPushButton(), "secret_init") - init_btn.clicked.connect(self._on_init) - unlock_layout.addWidget(init_btn) - unlock_btn = self._tr(QPushButton(), "secret_unlock") - unlock_btn.clicked.connect(self._on_unlock) - unlock_layout.addWidget(unlock_btn) - lock_btn = self._tr(QPushButton(), "secret_lock") - lock_btn.clicked.connect(self._on_lock) - unlock_layout.addWidget(lock_btn) root.addWidget(unlock_box) manage_box = self._tr(QGroupBox(), "secret_manage_group") manage_layout = QVBoxLayout(manage_box) manage_layout.addWidget(self._list) - button_row = QHBoxLayout() - add_btn = self._tr(QPushButton(), "secret_add") - add_btn.clicked.connect(self._on_add) - button_row.addWidget(add_btn) - remove_btn = self._tr(QPushButton(), "secret_remove") - remove_btn.clicked.connect(self._on_remove) - button_row.addWidget(remove_btn) - change_btn = self._tr(QPushButton(), "secret_change_passphrase") - change_btn.clicked.connect(self._on_change_passphrase) - button_row.addWidget(change_btn) - button_row.addStretch() - manage_layout.addLayout(button_row) root.addWidget(manage_box, stretch=1) root.addWidget(self._status_label) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("secret_unlock", self._on_unlock), + ("secret_lock", self._on_lock), + ("secret_add", self._on_add), + ("secret_remove", self._on_remove), + ("secret_init", self._on_init), + ("secret_change_passphrase", self._on_change_passphrase), + ] + def _refresh_status(self) -> None: manager = default_secret_manager if not manager.is_initialized: diff --git a/je_auto_control/gui/variables_tab.py b/je_auto_control/gui/variables_tab.py index dac0e592..08de6025 100644 --- a/je_auto_control/gui/variables_tab.py +++ b/je_auto_control/gui/variables_tab.py @@ -10,7 +10,7 @@ from PySide6.QtWidgets import ( QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMessageBox, - QPushButton, QTableWidget, QTableWidgetItem, QTextEdit, QVBoxLayout, + QTableWidget, QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget, ) @@ -60,21 +60,14 @@ def _update_table_headers(self) -> None: ]) def _build_layout(self) -> None: + # Refresh/clear/set/seed commands run from the Actions menu; the + # tab keeps only the table, the inputs, and the status line. root = QVBoxLayout(self) view_group = self._tr(QGroupBox(), "vars_current_group") view_layout = QVBoxLayout() self._update_table_headers() view_layout.addWidget(self._table) - view_btns = QHBoxLayout() - refresh_btn = self._tr(QPushButton(), "vars_refresh") - refresh_btn.clicked.connect(self._refresh) - clear_btn = self._tr(QPushButton(), "vars_clear") - clear_btn.clicked.connect(self._on_clear) - view_btns.addWidget(refresh_btn) - view_btns.addWidget(clear_btn) - view_btns.addStretch() - view_layout.addLayout(view_btns) view_group.setLayout(view_layout) root.addWidget(view_group) @@ -84,23 +77,26 @@ def _build_layout(self) -> None: set_layout.addWidget(self._set_name, stretch=1) set_layout.addWidget(self._tr(QLabel(), "vars_value_label")) set_layout.addWidget(self._set_value, stretch=2) - set_btn = self._tr(QPushButton(), "vars_set_btn") - set_btn.clicked.connect(self._on_set_one) - set_layout.addWidget(set_btn) set_group.setLayout(set_layout) root.addWidget(set_group) seed_group = self._tr(QGroupBox(), "vars_seed_group") seed_layout = QVBoxLayout() seed_layout.addWidget(self._seed_text) - seed_btn = self._tr(QPushButton(), "vars_seed_btn") - seed_btn.clicked.connect(self._on_seed_json) - seed_layout.addWidget(seed_btn) seed_group.setLayout(seed_layout) root.addWidget(seed_group) root.addWidget(self._status) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("vars_refresh", self._refresh), + ("vars_set_btn", self._on_set_one), + ("vars_seed_btn", self._on_seed_json), + ("vars_clear", self._on_clear), + ] + def _refresh(self) -> None: snapshot = executor.variables.as_dict() self._table.setRowCount(len(snapshot)) From cc9bf8ac3a07db1e1413140f8158a94b45816978 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 2 Jul 2026 19:51:33 +0800 Subject: [PATCH 2/7] Convert remaining feature tabs to the Actions menu Finish the menu-driven redesign started with the core tabs: every registered feature tab now exposes its commands through menu_actions() instead of in-tab buttons, so all tabs share the same minimal inputs-and-results layout. Buttons that a window-level menu cannot replace stay put: per-page browse buttons inside stacked trigger forms, the visibility-toggled data-source browse button, and stateful auto-refresh checkboxes. Button-text mutation and findChild retranslation plumbing become status-label updates, with re-entry already guarded in the handlers. Script Builder and Remote Desktop keep their interactive panel layouts. --- je_auto_control/gui/a11y_audit_tab.py | 17 +++--- je_auto_control/gui/accessibility_tab.py | 20 +++---- je_auto_control/gui/admin_console_tab.py | 36 ++++-------- je_auto_control/gui/assertions_tab.py | 13 ++-- je_auto_control/gui/audit_log_tab.py | 26 ++++---- je_auto_control/gui/chatops_tab.py | 24 ++++---- je_auto_control/gui/computer_use_tab.py | 20 +++---- je_auto_control/gui/dag_tab.py | 25 ++++---- je_auto_control/gui/data_source_tab.py | 11 +++- je_auto_control/gui/device_matrix_tab.py | 13 ++-- je_auto_control/gui/diagnostics_tab.py | 16 ++--- je_auto_control/gui/email_triggers_tab.py | 37 +++++------- je_auto_control/gui/flakiness_tab.py | 13 ++-- je_auto_control/gui/inspector_tab.py | 24 ++++---- je_auto_control/gui/live_hud_tab.py | 24 ++++---- je_auto_control/gui/llm_planner_tab.py | 32 +++------- je_auto_control/gui/media_checks_tab.py | 21 +++---- je_auto_control/gui/ocr_tab.py | 25 ++++---- je_auto_control/gui/plugins_tab.py | 17 +++--- je_auto_control/gui/presence_tab.py | 31 ++++------ je_auto_control/gui/profiler_tab.py | 30 ++++------ je_auto_control/gui/rest_api_tab.py | 39 +++++------- je_auto_control/gui/run_history_tab.py | 25 ++++---- je_auto_control/gui/scheduler_tab.py | 26 ++++---- je_auto_control/gui/self_healing_tab.py | 56 ++++-------------- je_auto_control/gui/test_suite_tab.py | 42 +++++-------- je_auto_control/gui/trace_replay_tab.py | 33 ++++------- je_auto_control/gui/triggers_tab.py | 24 +++----- je_auto_control/gui/usb_browser_tab.py | 23 ++++---- je_auto_control/gui/usb_devices_tab.py | 13 ++-- je_auto_control/gui/usb_passthrough_panel.py | 62 ++++++++------------ je_auto_control/gui/vlm_tab.py | 20 +++---- je_auto_control/gui/webhooks_tab.py | 32 ++++------ je_auto_control/gui/webrunner_tab.py | 42 +++++-------- je_auto_control/gui/window_tab.py | 23 ++++---- 35 files changed, 397 insertions(+), 538 deletions(-) diff --git a/je_auto_control/gui/a11y_audit_tab.py b/je_auto_control/gui/a11y_audit_tab.py index 078e2dfc..2bf6a662 100644 --- a/je_auto_control/gui/a11y_audit_tab.py +++ b/je_auto_control/gui/a11y_audit_tab.py @@ -7,7 +7,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QAbstractItemView, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QAbstractItemView, QHBoxLayout, QLabel, QLineEdit, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -55,25 +55,28 @@ def _apply_headers(self) -> None: self._table.setHorizontalHeaderLabels([_t(k) for k in _COLS]) def _build_layout(self) -> None: + # Audit/contrast commands run from the Actions menu; the tab keeps + # only the inputs, the issue table, and the summary line. root = QVBoxLayout(self) row = QHBoxLayout() row.addWidget(QLabel(_t("audit_app"))) row.addWidget(self._app, stretch=1) - run_btn = self._tr(QPushButton(), "audit_run") - run_btn.clicked.connect(self._on_run) - row.addWidget(run_btn) root.addLayout(row) crow = QHBoxLayout() crow.addWidget(QLabel(_t("audit_contrast_label"))) crow.addWidget(self._fg) crow.addWidget(self._bg) - contrast_btn = self._tr(QPushButton(), "audit_contrast_run") - contrast_btn.clicked.connect(self._on_contrast) - crow.addWidget(contrast_btn) root.addLayout(crow) root.addWidget(self._table, stretch=1) root.addWidget(self._summary) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("audit_run", self._on_run), + ("audit_contrast_run", self._on_contrast), + ] + def _on_run(self) -> None: app = self._app.text().strip() or None try: diff --git a/je_auto_control/gui/accessibility_tab.py b/je_auto_control/gui/accessibility_tab.py index 19c1c907..5a8e0919 100644 --- a/je_auto_control/gui/accessibility_tab.py +++ b/je_auto_control/gui/accessibility_tab.py @@ -4,7 +4,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QAbstractItemView, QHBoxLayout, QHeaderView, QLabel, QLineEdit, - QMessageBox, QPushButton, QTableWidget, QTableWidgetItem, + QMessageBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -59,6 +59,8 @@ def _apply_table_headers(self) -> None: ]) def _build_layout(self) -> None: + # Refresh/click commands run from the Actions menu; the tab keeps + # only the filter inputs, the element table, and the status line. root = QVBoxLayout(self) row = QHBoxLayout() row.addWidget(self._tr(QLabel(), "a11y_app_label")) @@ -67,19 +69,17 @@ def _build_layout(self) -> None: row.addWidget(self._tr(QLabel(), "a11y_name_label")) self._name_filter.setPlaceholderText(_t("a11y_name_placeholder")) row.addWidget(self._name_filter, stretch=1) - refresh = self._tr(QPushButton(), "a11y_refresh") - refresh.clicked.connect(self._refresh) - row.addWidget(refresh) root.addLayout(row) root.addWidget(self._table, stretch=1) - action_row = QHBoxLayout() - click_btn = self._tr(QPushButton(), "a11y_click_selected") - click_btn.clicked.connect(self._click_selected) - action_row.addWidget(click_btn) - action_row.addStretch() - root.addLayout(action_row) root.addWidget(self._status) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("a11y_refresh", self._refresh), + ("a11y_click_selected", self._click_selected), + ] + def _refresh(self) -> None: app = self._app_filter.text().strip() or None try: diff --git a/je_auto_control/gui/admin_console_tab.py b/je_auto_control/gui/admin_console_tab.py index 14af1087..3692f542 100644 --- a/je_auto_control/gui/admin_console_tab.py +++ b/je_auto_control/gui/admin_console_tab.py @@ -6,7 +6,7 @@ from PySide6.QtGui import QIcon, QImage, QPixmap from PySide6.QtWidgets import ( QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget, - QListWidgetItem, QMessageBox, QPushButton, QSpinBox, QTableWidget, + QListWidgetItem, QMessageBox, QSpinBox, QTableWidget, QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget, ) @@ -106,22 +106,30 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._apply_thumb_interval() def _build_layout(self) -> None: + # Add/remove/refresh/thumbnail/broadcast commands run from the + # Actions menu; the tab keeps only the inputs, tables, and output. root = QVBoxLayout(self) root.addWidget(self._build_add_group()) root.addWidget(self._table, stretch=1) - root.addLayout(self._build_button_row()) root.addWidget(self._build_thumbnails_group(), stretch=1) root.addWidget(self._build_broadcast_group(), stretch=1) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("admin_add", self._on_add), + ("admin_remove", self._on_remove), + ("admin_refresh", self._on_refresh), + ("admin_thumb_refresh_now", self._refresh_thumbnails), + ("admin_broadcast_run", self._on_broadcast), + ] + def _build_thumbnails_group(self) -> QGroupBox: group = self._tr(QGroupBox(), "admin_thumb_group") layout = QVBoxLayout(group) controls = QHBoxLayout() controls.addWidget(self._tr(QLabel(), "admin_thumb_interval")) controls.addWidget(self._thumb_interval) - refresh = self._tr(QPushButton(), "admin_thumb_refresh_now") - refresh.clicked.connect(self._refresh_thumbnails) - controls.addWidget(refresh) controls.addStretch(1) layout.addLayout(controls) layout.addWidget(self._thumbnails, stretch=1) @@ -136,31 +144,13 @@ def _build_add_group(self) -> QGroupBox: form.addWidget(self._url_input, stretch=1) form.addWidget(self._tr(QLabel(), "admin_token")) form.addWidget(self._token_input) - add = self._tr(QPushButton(), "admin_add") - add.clicked.connect(self._on_add) - form.addWidget(add) return group - def _build_button_row(self) -> QHBoxLayout: - row = QHBoxLayout() - for key, handler in ( - ("admin_remove", self._on_remove), - ("admin_refresh", self._on_refresh), - ): - btn = self._tr(QPushButton(), key) - btn.clicked.connect(handler) - row.addWidget(btn) - row.addStretch(1) - return row - def _build_broadcast_group(self) -> QGroupBox: group = self._tr(QGroupBox(), "admin_broadcast_group") form = QVBoxLayout(group) form.addWidget(self._tr(QLabel(), "admin_actions_label")) form.addWidget(self._actions_input) - run = self._tr(QPushButton(), "admin_broadcast_run") - run.clicked.connect(self._on_broadcast) - form.addWidget(run) form.addWidget(self._tr(QLabel(), "admin_results_label")) form.addWidget(self._broadcast_output, stretch=1) return group diff --git a/je_auto_control/gui/assertions_tab.py b/je_auto_control/gui/assertions_tab.py index 75629c71..bbc558f2 100644 --- a/je_auto_control/gui/assertions_tab.py +++ b/je_auto_control/gui/assertions_tab.py @@ -7,7 +7,7 @@ from typing import Any, Dict, Optional from PySide6.QtWidgets import ( - QCheckBox, QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, + QCheckBox, QComboBox, QHBoxLayout, QLabel, QLineEdit, QVBoxLayout, QWidget, ) @@ -51,6 +51,8 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._sync_visibility() def _build_layout(self) -> None: + # The run command runs from the Actions menu; the tab keeps only + # the assertion form and the result label. root = QVBoxLayout(self) krow = QHBoxLayout() krow.addWidget(QLabel(_t("assert_kind"))) @@ -71,12 +73,15 @@ def _build_layout(self) -> None: root.addWidget(self._expect) root.addWidget(self._regex) - run_btn = self._tr(QPushButton(), "assert_run") - run_btn.clicked.connect(self._on_run) - root.addWidget(run_btn) root.addWidget(self._result) root.addStretch() + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("assert_run", self._on_run), + ] + def _current_kind(self) -> str: return _KINDS[self._kind.currentIndex()] diff --git a/je_auto_control/gui/audit_log_tab.py b/je_auto_control/gui/audit_log_tab.py index 75f428d6..f5a6776b 100644 --- a/je_auto_control/gui/audit_log_tab.py +++ b/je_auto_control/gui/audit_log_tab.py @@ -4,7 +4,7 @@ from PySide6.QtWidgets import ( QComboBox, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, - QMessageBox, QPushButton, QSpinBox, QTableWidget, QTableWidgetItem, + QMessageBox, QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -72,12 +72,21 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._refresh() def _build_layout(self) -> None: + # Refresh/verify/clear commands run from the Actions menu; the + # tab keeps only the filter inputs, the table, and the status. root = QVBoxLayout(self) root.addWidget(self._build_filter_group()) root.addWidget(self._table, stretch=1) - root.addLayout(self._build_button_row()) root.addWidget(self._verify_status) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("audit_refresh", self._refresh), + ("audit_verify", self._verify), + ("audit_clear", self._clear), + ] + def _build_filter_group(self) -> QGroupBox: group = self._tr(QGroupBox(), "audit_filter_group") row = QHBoxLayout(group) @@ -89,19 +98,6 @@ def _build_filter_group(self) -> QGroupBox: row.addWidget(self._limit_input) return group - def _build_button_row(self) -> QHBoxLayout: - row = QHBoxLayout() - for key, handler in ( - ("audit_refresh", self._refresh), - ("audit_verify", self._verify), - ("audit_clear", self._clear), - ): - btn = self._tr(QPushButton(), key) - btn.clicked.connect(handler) - row.addWidget(btn) - row.addStretch(1) - return row - def _refresh(self) -> None: self._apply_table_headers() # Pull a wide window so the dropdown reflects everything the user diff --git a/je_auto_control/gui/chatops_tab.py b/je_auto_control/gui/chatops_tab.py index 7d2debd6..e7d1ce61 100644 --- a/je_auto_control/gui/chatops_tab.py +++ b/je_auto_control/gui/chatops_tab.py @@ -2,7 +2,7 @@ from typing import Optional from PySide6.QtWidgets import ( - QFileDialog, QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextEdit, + QFileDialog, QHBoxLayout, QLabel, QLineEdit, QTextEdit, QVBoxLayout, QWidget, ) @@ -39,26 +39,19 @@ def retranslate(self) -> None: self._apply_translations() def _build_layout(self) -> None: + # Browse/send commands run from the Actions menu; the tab keeps + # only the root/command inputs and the output log. root = QVBoxLayout(self) root_row = QHBoxLayout() - root_row.addWidget(QLabel(), stretch=0) self._root_label = QLabel() root_row.addWidget(self._root_label) root_row.addWidget(self._script_root, stretch=1) - browse = QPushButton() - browse.setObjectName("chatops_browse_btn") - browse.clicked.connect(self._on_browse) - root_row.addWidget(browse) root.addLayout(root_row) cmd_row = QHBoxLayout() self._cmd_label = QLabel() cmd_row.addWidget(self._cmd_label) cmd_row.addWidget(self._command_input, stretch=1) - send = QPushButton() - send.setObjectName("chatops_send_btn") - send.clicked.connect(self._on_send) - cmd_row.addWidget(send) root.addLayout(cmd_row) self._output_label = QLabel() @@ -66,14 +59,17 @@ def _build_layout(self) -> None: root.addWidget(self._output, stretch=1) self._apply_translations() + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("chatops_browse_btn", self._on_browse), + ("chatops_send_btn", self._on_send), + ] + def _apply_translations(self) -> None: self._root_label.setText(_t("chatops_root_label")) self._cmd_label.setText(_t("chatops_cmd_label")) self._output_label.setText(_t("chatops_output_label")) - for key in ("chatops_browse_btn", "chatops_send_btn"): - widget = self.findChild(QPushButton, key) - if widget is not None: - widget.setText(_t(key)) self._script_root.setPlaceholderText(_t("chatops_root_placeholder")) self._command_input.setPlaceholderText( _t("chatops_cmd_placeholder"), diff --git a/je_auto_control/gui/computer_use_tab.py b/je_auto_control/gui/computer_use_tab.py index 6e0761e2..8e693d01 100644 --- a/je_auto_control/gui/computer_use_tab.py +++ b/je_auto_control/gui/computer_use_tab.py @@ -4,7 +4,7 @@ from PySide6.QtCore import QObject, QThread, Signal from PySide6.QtWidgets import ( - QFormLayout, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, + QFormLayout, QLabel, QLineEdit, QMessageBox, QSpinBox, QTextEdit, QVBoxLayout, QWidget, ) @@ -58,7 +58,6 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._max_tokens = QSpinBox() self._max_tokens.setRange(64, 8192) self._max_tokens.setValue(1024) - self._run_btn = QPushButton() self._output = QTextEdit() self._output.setReadOnly(True) self._status = QLabel() @@ -73,6 +72,8 @@ def retranslate(self) -> None: # --- layout ---------------------------------------------------- def _build_layout(self) -> None: + # The run command runs from the Actions menu; the tab keeps only + # the goal/model/limit inputs, the output view, and the status. root = QVBoxLayout(self) form = QFormLayout() self._goal_label = QLabel() @@ -86,11 +87,6 @@ def _build_layout(self) -> None: form.addRow(self._wall_seconds_label, self._wall_seconds) form.addRow(self._max_tokens_label, self._max_tokens) root.addLayout(form) - btn_row = QHBoxLayout() - self._run_btn.clicked.connect(self._on_run) - btn_row.addWidget(self._run_btn) - btn_row.addStretch() - root.addLayout(btn_row) root.addWidget(self._status) self._output_label = QLabel() root.addWidget(self._output_label) @@ -105,7 +101,12 @@ def _apply_translations(self) -> None: self._max_tokens_label.setText(_t("computer_use_max_tokens_label")) self._output_label.setText(_t("computer_use_output_label")) self._goal_input.setPlaceholderText(_t("computer_use_goal_placeholder")) - self._run_btn.setText(_t("computer_use_run_btn")) + + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("computer_use_run_btn", self._on_run), + ] # --- run path -------------------------------------------------- @@ -128,7 +129,6 @@ def _on_run(self) -> None: "max_tokens": int(self._max_tokens.value()), } self._status.setText(_t("computer_use_running")) - self._run_btn.setEnabled(False) self._spawn_worker(params) def _spawn_worker(self, params: dict) -> None: @@ -147,7 +147,6 @@ def _spawn_worker(self, params: dict) -> None: thread.start() def _on_worker_finished(self, data: dict) -> None: - self._run_btn.setEnabled(True) ok = bool(data.get("succeeded")) key = "computer_use_success" if ok else "computer_use_failure" self._status.setText(_t(key)) @@ -158,7 +157,6 @@ def _on_worker_finished(self, data: dict) -> None: self._worker = None def _on_worker_failed(self, message: str) -> None: - self._run_btn.setEnabled(True) self._status.setText(f"{_t('computer_use_error')}: {message}") self._thread = None self._worker = None diff --git a/je_auto_control/gui/dag_tab.py b/je_auto_control/gui/dag_tab.py index 19841139..9ad391be 100644 --- a/je_auto_control/gui/dag_tab.py +++ b/je_auto_control/gui/dag_tab.py @@ -4,7 +4,7 @@ from PySide6.QtCore import QObject, Qt, QThread, Signal from PySide6.QtWidgets import ( - QFileDialog, QHBoxLayout, QLabel, QPushButton, QSpinBox, + QFileDialog, QHBoxLayout, QLabel, QSpinBox, QTableWidget, QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget, ) @@ -65,17 +65,10 @@ def retranslate(self) -> None: self._apply_translations() def _build_layout(self) -> None: + # Load/validate/run commands run from the Actions menu; the tab + # keeps only the editor, the parallel input, table, and status. root = QVBoxLayout(self) controls = QHBoxLayout() - for label_key, slot in ( - ("dag_load_btn", self._on_load), - ("dag_validate_btn", self._on_validate), - ("dag_run_btn", self._on_run), - ): - btn = QPushButton() - btn.setObjectName(label_key) - btn.clicked.connect(slot) - controls.addWidget(btn) controls.addWidget(QLabel(_t("dag_parallel_label"))) controls.addWidget(self._max_parallel) controls.addStretch() @@ -85,11 +78,15 @@ def _build_layout(self) -> None: root.addWidget(self._table, stretch=3) self._apply_translations() + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("dag_load_btn", self._on_load), + ("dag_validate_btn", self._on_validate), + ("dag_run_btn", self._on_run), + ] + def _apply_translations(self) -> None: - for key in ("dag_load_btn", "dag_validate_btn", "dag_run_btn"): - btn = self.findChild(QPushButton, key) - if btn is not None: - btn.setText(_t(key)) self._table.setHorizontalHeaderLabels( [_t(f"dag_col_{name}") for name in _COLUMNS], ) diff --git a/je_auto_control/gui/data_source_tab.py b/je_auto_control/gui/data_source_tab.py index 8845e084..8d2a1c5e 100644 --- a/je_auto_control/gui/data_source_tab.py +++ b/je_auto_control/gui/data_source_tab.py @@ -45,6 +45,8 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._sync_visibility(self._kind.currentText()) def _build_layout(self) -> None: + # The load command runs from the Actions menu; the tab keeps the + # source form (with its kind-synced browse button) and the table. root = QVBoxLayout(self) row1 = QHBoxLayout() row1.addWidget(QLabel(_t("ds_kind"))) @@ -72,15 +74,18 @@ def _build_layout(self) -> None: row2 = QHBoxLayout() row2.addWidget(QLabel(_t("ds_limit"))) row2.addWidget(self._limit) - load_btn = self._tr(QPushButton(), "ds_load") - load_btn.clicked.connect(self._on_load) - row2.addWidget(load_btn) row2.addStretch() root.addLayout(row2) root.addWidget(self._table, stretch=1) root.addWidget(self._status) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("ds_load", self._on_load), + ] + def _sync_visibility(self, kind: str) -> None: is_file = kind in ("csv", "json", "sqlite", "excel") for widget in (self._path_label, self._path, self._browse_btn): diff --git a/je_auto_control/gui/device_matrix_tab.py b/je_auto_control/gui/device_matrix_tab.py index addfc785..51755e5d 100644 --- a/je_auto_control/gui/device_matrix_tab.py +++ b/je_auto_control/gui/device_matrix_tab.py @@ -7,7 +7,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QAbstractItemView, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, + QAbstractItemView, QHBoxLayout, QLabel, QPlainTextEdit, QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -58,6 +58,8 @@ def _apply_headers(self) -> None: self._table.setHorizontalHeaderLabels([_t(k) for k in _COLS]) def _build_layout(self) -> None: + # The run command runs from the Actions menu; the tab keeps only + # the device/action editors, the result table, and the summary. root = QVBoxLayout(self) root.addWidget(QLabel(_t("dm_devices"))) root.addWidget(self._devices) @@ -66,14 +68,17 @@ def _build_layout(self) -> None: row = QHBoxLayout() row.addWidget(QLabel(_t("dm_parallel"))) row.addWidget(self._parallel) - run_btn = self._tr(QPushButton(), "dm_run") - run_btn.clicked.connect(self._on_run) - row.addWidget(run_btn) row.addStretch() root.addLayout(row) root.addWidget(self._table, stretch=1) root.addWidget(self._summary) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("dm_run", self._on_run), + ] + def _on_run(self) -> None: try: devices = json.loads(self._devices.toPlainText() or "[]") diff --git a/je_auto_control/gui/diagnostics_tab.py b/je_auto_control/gui/diagnostics_tab.py index 4f2ecd5e..89a205a9 100644 --- a/je_auto_control/gui/diagnostics_tab.py +++ b/je_auto_control/gui/diagnostics_tab.py @@ -3,7 +3,7 @@ from PySide6.QtGui import QBrush, QColor from PySide6.QtWidgets import ( - QHBoxLayout, QHeaderView, QLabel, QPushButton, QTableWidget, + QHeaderView, QLabel, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -42,16 +42,18 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._refresh() def _build_layout(self) -> None: + # The run command runs from the Actions menu; the tab keeps only + # the summary label and the results table. root = QVBoxLayout(self) - header = QHBoxLayout() - run_btn = self._tr(QPushButton(), "diag_run") - run_btn.clicked.connect(self._refresh) - header.addWidget(run_btn) - header.addStretch(1) - root.addLayout(header) root.addWidget(self._summary_label) root.addWidget(self._table, stretch=1) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("diag_run", self._refresh), + ] + def _apply_table_headers(self) -> None: self._table.setHorizontalHeaderLabels([ _t("diag_col_name"), _t("diag_col_severity"), diff --git a/je_auto_control/gui/email_triggers_tab.py b/je_auto_control/gui/email_triggers_tab.py index 275867fc..5d61f750 100644 --- a/je_auto_control/gui/email_triggers_tab.py +++ b/je_auto_control/gui/email_triggers_tab.py @@ -4,7 +4,7 @@ from PySide6.QtCore import QTimer, Qt from PySide6.QtWidgets import ( QAbstractItemView, QCheckBox, QFileDialog, QGroupBox, QHBoxLayout, - QHeaderView, QLabel, QLineEdit, QMessageBox, QPushButton, + QHeaderView, QLabel, QLineEdit, QMessageBox, QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -80,19 +80,13 @@ def _apply_table_headers(self) -> None: ]) def _build_layout(self) -> None: + # Start/stop/poll/browse/register/remove commands run from the + # Actions menu; the tab keeps only the inputs, the table, and the + # status line. root = QVBoxLayout(self) engine_box = self._tr(QGroupBox(), "eml_engine_group") engine_layout = QHBoxLayout(engine_box) - start_btn = self._tr(QPushButton(), "eml_start") - start_btn.clicked.connect(self._on_start) - engine_layout.addWidget(start_btn) - stop_btn = self._tr(QPushButton(), "eml_stop") - stop_btn.clicked.connect(self._on_stop) - engine_layout.addWidget(stop_btn) - poll_btn = self._tr(QPushButton(), "eml_poll_now") - poll_btn.clicked.connect(self._on_poll_now) - engine_layout.addWidget(poll_btn) engine_layout.addWidget(self._status_label) engine_layout.addStretch() root.addWidget(engine_box) @@ -127,22 +121,21 @@ def _build_layout(self) -> None: script_row = QHBoxLayout() script_row.addWidget(self._tr(QLabel(), "eml_script_label")) script_row.addWidget(self._script_input) - browse_btn = self._tr(QPushButton(), "eml_browse") - browse_btn.clicked.connect(self._on_browse) - script_row.addWidget(browse_btn) - register_btn = self._tr(QPushButton(), "eml_register") - register_btn.clicked.connect(self._on_register) - script_row.addWidget(register_btn) add_layout.addLayout(script_row) root.addWidget(add_box) root.addWidget(self._table, stretch=1) - action_row = QHBoxLayout() - remove_btn = self._tr(QPushButton(), "eml_remove") - remove_btn.clicked.connect(self._on_remove) - action_row.addWidget(remove_btn) - action_row.addStretch() - root.addLayout(action_row) + + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("eml_start", self._on_start), + ("eml_stop", self._on_stop), + ("eml_poll_now", self._on_poll_now), + ("eml_browse", self._on_browse), + ("eml_register", self._on_register), + ("eml_remove", self._on_remove), + ] def _on_start(self) -> None: default_email_trigger_watcher.start() diff --git a/je_auto_control/gui/flakiness_tab.py b/je_auto_control/gui/flakiness_tab.py index 973b9ba9..6536a0c3 100644 --- a/je_auto_control/gui/flakiness_tab.py +++ b/je_auto_control/gui/flakiness_tab.py @@ -7,7 +7,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QAbstractItemView, QComboBox, QHBoxLayout, QHeaderView, QLabel, QPushButton, + QAbstractItemView, QComboBox, QHBoxLayout, QHeaderView, QLabel, QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -64,6 +64,8 @@ def _apply_headers(self) -> None: self._table.setHorizontalHeaderLabels([_t(k) for k in _COLUMN_KEYS]) def _build_layout(self) -> None: + # The refresh command runs from the Actions menu; the tab keeps + # only the filter inputs, the table, and the status line. root = QVBoxLayout(self) controls = QHBoxLayout() controls.addWidget(QLabel(_t("flaky_limit"))) @@ -72,14 +74,17 @@ def _build_layout(self) -> None: controls.addWidget(self._min_runs) controls.addWidget(QLabel(_t("flaky_group_by"))) controls.addWidget(self._group_by) - refresh_btn = self._tr(QPushButton(), "flaky_refresh") - refresh_btn.clicked.connect(self._refresh) - controls.addWidget(refresh_btn) controls.addStretch() root.addLayout(controls) root.addWidget(self._table, stretch=1) root.addWidget(self._status) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("flaky_refresh", self._refresh), + ] + def _refresh(self) -> None: report = analyze_flakiness( limit=self._limit.value(), diff --git a/je_auto_control/gui/inspector_tab.py b/je_auto_control/gui/inspector_tab.py index 35e5d594..a8c8a763 100644 --- a/je_auto_control/gui/inspector_tab.py +++ b/je_auto_control/gui/inspector_tab.py @@ -3,7 +3,7 @@ from PySide6.QtCore import QTimer from PySide6.QtWidgets import ( - QFormLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QPushButton, + QFormLayout, QGroupBox, QHeaderView, QLabel, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -48,12 +48,20 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._timer.start() def _build_layout(self) -> None: + # Refresh/reset commands run from the Actions menu; the tab keeps + # only the summary, the metrics group, and the samples table. root = QVBoxLayout(self) root.addWidget(self._summary_label) root.addWidget(self._build_metrics_group()) - root.addLayout(self._build_button_row()) root.addWidget(self._table, stretch=1) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("inspector_refresh", self._refresh), + ("inspector_reset", self._reset), + ] + def _build_metrics_group(self) -> QGroupBox: group = self._tr(QGroupBox(), "inspector_metrics_group") form = QFormLayout(group) @@ -64,18 +72,6 @@ def _build_metrics_group(self) -> QGroupBox: form.addRow(label_widget, value_widget) return group - def _build_button_row(self) -> QHBoxLayout: - row = QHBoxLayout() - for key, handler in ( - ("inspector_refresh", self._refresh), - ("inspector_reset", self._reset), - ): - btn = self._tr(QPushButton(), key) - btn.clicked.connect(handler) - row.addWidget(btn) - row.addStretch(1) - return row - def _apply_table_headers(self) -> None: self._table.setHorizontalHeaderLabels([ _t("inspector_col_age"), _t("inspector_metric_rtt_ms"), diff --git a/je_auto_control/gui/live_hud_tab.py b/je_auto_control/gui/live_hud_tab.py index 95649a22..58842925 100644 --- a/je_auto_control/gui/live_hud_tab.py +++ b/je_auto_control/gui/live_hud_tab.py @@ -3,7 +3,7 @@ from PySide6.QtCore import QTimer from PySide6.QtWidgets import ( - QGroupBox, QHBoxLayout, QLabel, QPushButton, QTextEdit, QVBoxLayout, QWidget, + QGroupBox, QLabel, QTextEdit, QVBoxLayout, QWidget, ) from je_auto_control.gui._i18n_helpers import TranslatableMixin @@ -48,6 +48,8 @@ def retranslate(self) -> None: self._apply_position_labels() def _build_layout(self) -> None: + # Start/stop/clear commands run from the Actions menu; the tab + # keeps only the watcher labels and the log view. root = QVBoxLayout(self) status_group = self._tr(QGroupBox(), "hud_watchers_group") status_layout = QVBoxLayout() @@ -56,21 +58,17 @@ def _build_layout(self) -> None: status_group.setLayout(status_layout) root.addWidget(status_group) - ctl = QHBoxLayout() - start_btn = self._tr(QPushButton(), "hud_start") - start_btn.clicked.connect(self._start) - stop_btn = self._tr(QPushButton(), "hud_stop") - stop_btn.clicked.connect(self._stop) - clear_btn = self._tr(QPushButton(), "hud_clear") - clear_btn.clicked.connect(self._log_view.clear) - for btn in (start_btn, stop_btn, clear_btn): - ctl.addWidget(btn) - ctl.addStretch() - root.addLayout(ctl) - root.addWidget(self._tr(QLabel(), "hud_recent_log")) root.addWidget(self._log_view, stretch=1) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("hud_start", self._start), + ("hud_stop", self._stop), + ("hud_clear", self._log_view.clear), + ] + def _start(self) -> None: self._log_tail.attach(autocontrol_logger) self._timer.start() diff --git a/je_auto_control/gui/llm_planner_tab.py b/je_auto_control/gui/llm_planner_tab.py index 348ba218..83bc7352 100644 --- a/je_auto_control/gui/llm_planner_tab.py +++ b/je_auto_control/gui/llm_planner_tab.py @@ -10,7 +10,7 @@ from PySide6.QtCore import QObject, QThread, Signal from PySide6.QtWidgets import ( - QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QTextEdit, QVBoxLayout, QWidget, ) @@ -69,13 +69,10 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._result_view.setReadOnly(True) self._status = QLabel() self._planned_actions: Optional[list] = None - self._plan_btn: Optional[QPushButton] = None - self._run_btn: Optional[QPushButton] = None self._plan_thread: Optional[QThread] = None self._plan_worker: Optional[_PlanWorker] = None self._build_layout() self._apply_placeholders() - self._set_run_enabled(False) def retranslate(self) -> None: TranslatableMixin.retranslate(self) @@ -86,6 +83,8 @@ def _apply_placeholders(self) -> None: self._model.setPlaceholderText(_t("llm_model_placeholder")) def _build_layout(self) -> None: + # Plan/run commands run from the Actions menu; the tab keeps only + # the description/model inputs, the plan/result views, and status. root = QVBoxLayout(self) desc_group = self._tr(QGroupBox(), "llm_desc_group") @@ -95,15 +94,6 @@ def _build_layout(self) -> None: model_row.addWidget(self._tr(QLabel(), "llm_model_label")) model_row.addWidget(self._model, stretch=1) desc_layout.addLayout(model_row) - btn_row = QHBoxLayout() - self._plan_btn = self._tr(QPushButton(), "llm_plan_btn") - self._plan_btn.clicked.connect(self._on_plan) - self._run_btn = self._tr(QPushButton(), "llm_run_btn") - self._run_btn.clicked.connect(self._on_run) - btn_row.addWidget(self._plan_btn) - btn_row.addWidget(self._run_btn) - btn_row.addStretch() - desc_layout.addLayout(btn_row) desc_group.setLayout(desc_layout) root.addWidget(desc_group) @@ -121,9 +111,12 @@ def _build_layout(self) -> None: root.addWidget(self._status) - def _set_run_enabled(self, enabled: bool) -> None: - if self._run_btn is not None: - self._run_btn.setEnabled(enabled) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("llm_plan_btn", self._on_plan), + ("llm_run_btn", self._on_run), + ] def _on_plan(self) -> None: description = self._description.toPlainText().strip() @@ -133,12 +126,9 @@ def _on_plan(self) -> None: if self._plan_thread is not None and self._plan_thread.isRunning(): return model = self._model.text().strip() or None - if self._plan_btn is not None: - self._plan_btn.setEnabled(False) self._status.setText(_t("llm_planning")) self._actions_view.clear() self._planned_actions = None - self._set_run_enabled(False) worker = _PlanWorker(description, model, sorted(executor.known_commands())) thread = QThread(self) worker.moveToThread(thread) @@ -160,11 +150,9 @@ def _on_plan_finished(self, actions: list) -> None: self._status.setText( _t("llm_plan_count").replace("{n}", str(len(actions))) ) - self._set_run_enabled(bool(actions)) def _on_plan_failed(self, message: str) -> None: self._planned_actions = None - self._set_run_enabled(False) QMessageBox.warning(self, _t("llm_plan_btn"), message) self._status.setText(message) @@ -175,8 +163,6 @@ def _on_thread_done(self) -> None: self._plan_worker.deleteLater() self._plan_thread = None self._plan_worker = None - if self._plan_btn is not None: - self._plan_btn.setEnabled(True) def _on_run(self) -> None: if not self._planned_actions: diff --git a/je_auto_control/gui/media_checks_tab.py b/je_auto_control/gui/media_checks_tab.py index 20bc6218..ec00328d 100644 --- a/je_auto_control/gui/media_checks_tab.py +++ b/je_auto_control/gui/media_checks_tab.py @@ -7,7 +7,7 @@ from PySide6.QtWidgets import ( QCheckBox, QDoubleSpinBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, - QPushButton, QVBoxLayout, QWidget, + QVBoxLayout, QWidget, ) from je_auto_control.gui._i18n_helpers import TranslatableMixin @@ -47,6 +47,8 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._build_layout() def _build_layout(self) -> None: + # Audio/video check and browse commands run from the Actions menu; + # the tab keeps only the inputs and the result label. root = QVBoxLayout(self) root.addWidget(QLabel(_t("media_audio_label"))) arow = QHBoxLayout() @@ -55,9 +57,6 @@ def _build_layout(self) -> None: arow.addWidget(QLabel(_t("media_audio_threshold"))) arow.addWidget(self._audio_threshold) arow.addWidget(self._audio_expect) - audio_btn = self._tr(QPushButton(), "media_audio_run") - audio_btn.clicked.connect(self._on_audio) - arow.addWidget(audio_btn) arow.addStretch() root.addLayout(arow) @@ -65,23 +64,25 @@ def _build_layout(self) -> None: vrow = QHBoxLayout() vrow.addWidget(QLabel(_t("media_video_path"))) vrow.addWidget(self._video_path, stretch=1) - browse_btn = self._tr(QPushButton(), "media_video_browse") - browse_btn.clicked.connect(self._on_browse) - vrow.addWidget(browse_btn) root.addLayout(vrow) vrow2 = QHBoxLayout() vrow2.addWidget(QLabel(_t("media_video_threshold"))) vrow2.addWidget(self._video_threshold) vrow2.addWidget(self._video_expect) - video_btn = self._tr(QPushButton(), "media_video_run") - video_btn.clicked.connect(self._on_video) - vrow2.addWidget(video_btn) vrow2.addStretch() root.addLayout(vrow2) root.addWidget(self._result) root.addStretch() + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("media_audio_run", self._on_audio), + ("media_video_browse", self._on_browse), + ("media_video_run", self._on_video), + ] + def _on_browse(self) -> None: path, _ = QFileDialog.getOpenFileName(self, _t("media_video_browse")) if path: diff --git a/je_auto_control/gui/ocr_tab.py b/je_auto_control/gui/ocr_tab.py index 4a0a4322..bc41c9c2 100644 --- a/je_auto_control/gui/ocr_tab.py +++ b/je_auto_control/gui/ocr_tab.py @@ -4,7 +4,7 @@ from typing import Optional from PySide6.QtWidgets import ( - QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QTextEdit, QVBoxLayout, QWidget, ) @@ -59,15 +59,14 @@ def _apply_placeholders(self) -> None: self._regex.setPlaceholderText(_t("ocr_regex_placeholder")) def _build_layout(self) -> None: + # Pick/dump/find commands run from the Actions menu; the tab keeps + # only the region/params/regex inputs, the results view, and status. root = QVBoxLayout(self) region_group = self._tr(QGroupBox(), "ocr_region_group") region_layout = QHBoxLayout() region_layout.addWidget(self._tr(QLabel(), "ocr_region_label")) region_layout.addWidget(self._region, stretch=1) - pick_btn = self._tr(QPushButton(), "ocr_pick_region") - pick_btn.clicked.connect(self._on_pick_region) - region_layout.addWidget(pick_btn) region_group.setLayout(region_layout) root.addWidget(region_group) @@ -84,20 +83,18 @@ def _build_layout(self) -> None: regex_layout.addWidget(self._regex, stretch=1) root.addLayout(regex_layout) - btn_row = QHBoxLayout() - dump_btn = self._tr(QPushButton(), "ocr_dump_region") - dump_btn.clicked.connect(self._on_dump) - find_btn = self._tr(QPushButton(), "ocr_find_regex") - find_btn.clicked.connect(self._on_find_regex) - btn_row.addWidget(dump_btn) - btn_row.addWidget(find_btn) - btn_row.addStretch() - root.addLayout(btn_row) - root.addWidget(self._tr(QLabel(), "ocr_results_label")) root.addWidget(self._result, stretch=1) root.addWidget(self._status) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("ocr_pick_region", self._on_pick_region), + ("ocr_dump_region", self._on_dump), + ("ocr_find_regex", self._on_find_regex), + ] + def _on_pick_region(self) -> None: region = open_region_selector(self) if region is None: diff --git a/je_auto_control/gui/plugins_tab.py b/je_auto_control/gui/plugins_tab.py index dd4f0a57..82baa40a 100644 --- a/je_auto_control/gui/plugins_tab.py +++ b/je_auto_control/gui/plugins_tab.py @@ -3,7 +3,7 @@ from PySide6.QtWidgets import ( QFileDialog, QHBoxLayout, QLabel, QLineEdit, QListWidget, QMessageBox, - QPushButton, QVBoxLayout, QWidget, + QVBoxLayout, QWidget, ) from je_auto_control.gui._i18n_helpers import TranslatableMixin @@ -33,21 +33,24 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._build_layout() def _build_layout(self) -> None: + # Browse/load commands run from the Actions menu; the tab keeps + # only the directory input, the command list, and the status. root = QVBoxLayout(self) form = QHBoxLayout() form.addWidget(self._tr(QLabel(), "pl_dir_label")) form.addWidget(self._dir_input, stretch=1) - browse = self._tr(QPushButton(), "browse") - browse.clicked.connect(self._browse) - form.addWidget(browse) - load = self._tr(QPushButton(), "pl_load") - load.clicked.connect(self._on_load) - form.addWidget(load) root.addLayout(form) root.addWidget(self._tr(QLabel(), "pl_registered_label")) root.addWidget(self._list, stretch=1) root.addWidget(self._status) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("browse", self._browse), + ("pl_load", self._on_load), + ] + def retranslate(self) -> None: TranslatableMixin.retranslate(self) if self._status_is_translatable: diff --git a/je_auto_control/gui/presence_tab.py b/je_auto_control/gui/presence_tab.py index 0d981506..7e79ac9a 100644 --- a/je_auto_control/gui/presence_tab.py +++ b/je_auto_control/gui/presence_tab.py @@ -9,7 +9,7 @@ from PySide6.QtCore import Qt, QTimer from PySide6.QtWidgets import ( - QHBoxLayout, QHeaderView, QLabel, QPushButton, QTableWidget, + QHeaderView, QLabel, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -56,32 +56,25 @@ def retranslate(self) -> None: # --- layout ---------------------------------------------------- def _build_layout(self) -> None: + # Refresh/promote/demote/kick commands run from the Actions menu; + # the tab keeps only the roster table and the status line. root = QVBoxLayout(self) - controls = QHBoxLayout() - for key, slot in ( - ("presence_refresh_btn", self.refresh), - ("presence_promote_btn", self._on_promote), - ("presence_demote_btn", self._on_demote), - ("presence_kick_btn", self._on_kick), - ): - btn = QPushButton() - btn.setObjectName(key) - btn.clicked.connect(slot) - controls.addWidget(btn) - controls.addStretch() - root.addLayout(controls) root.addWidget(self._table, stretch=1) root.addWidget(self._status) header = self._table.horizontalHeader() header.setSectionResizeMode(QHeaderView.Stretch) self._apply_translations() + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("presence_refresh_btn", self.refresh), + ("presence_promote_btn", self._on_promote), + ("presence_demote_btn", self._on_demote), + ("presence_kick_btn", self._on_kick), + ] + def _apply_translations(self) -> None: - for key in ("presence_refresh_btn", "presence_promote_btn", - "presence_demote_btn", "presence_kick_btn"): - btn = self.findChild(QPushButton, key) - if btn is not None: - btn.setText(_t(key)) self._table.setHorizontalHeaderLabels( [_t(f"presence_col_{col}") for col in _COLUMNS], ) diff --git a/je_auto_control/gui/profiler_tab.py b/je_auto_control/gui/profiler_tab.py index 853f41f7..9f6c5463 100644 --- a/je_auto_control/gui/profiler_tab.py +++ b/je_auto_control/gui/profiler_tab.py @@ -3,8 +3,8 @@ from PySide6.QtCore import QTimer, Qt from PySide6.QtWidgets import ( - QAbstractItemView, QHBoxLayout, QHeaderView, QLabel, QProgressBar, - QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, + QAbstractItemView, QHeaderView, QLabel, QProgressBar, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from je_auto_control.gui._i18n_helpers import TranslatableMixin @@ -69,23 +69,21 @@ def _apply_table_headers(self) -> None: ]) def _build_layout(self) -> None: + # Enable/reset/refresh commands run from the Actions menu; the tab + # keeps only the table, the total bar, and the status line. root = QVBoxLayout(self) - controls = QHBoxLayout() - self._enable_btn = self._tr(QPushButton(), "prof_enable") - self._enable_btn.clicked.connect(self._toggle_enable) - controls.addWidget(self._enable_btn) - reset_btn = self._tr(QPushButton(), "prof_reset") - reset_btn.clicked.connect(self._on_reset) - controls.addWidget(reset_btn) - refresh_btn = self._tr(QPushButton(), "prof_refresh") - refresh_btn.clicked.connect(self._refresh) - controls.addWidget(refresh_btn) - controls.addStretch() - root.addLayout(controls) root.addWidget(self._table, stretch=1) root.addWidget(self._totalbar) root.addWidget(self._status) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("prof_enable", self._toggle_enable), + ("prof_reset", self._on_reset), + ("prof_refresh", self._refresh), + ] + def _toggle_enable(self) -> None: if default_profiler.enabled: default_profiler.disable() @@ -115,10 +113,6 @@ def _refresh(self) -> None: running_text = _t("prof_running") if default_profiler.enabled \ else _t("prof_paused") self._status.setText(running_text) - self._enable_btn.setText( - _t("prof_disable") if default_profiler.enabled - else _t("prof_enable"), - ) def _set_row(self, row: int, stats: ActionStats, share: float) -> None: values = ( diff --git a/je_auto_control/gui/rest_api_tab.py b/je_auto_control/gui/rest_api_tab.py index a2ff701d..cd127441 100644 --- a/je_auto_control/gui/rest_api_tab.py +++ b/je_auto_control/gui/rest_api_tab.py @@ -8,7 +8,7 @@ from PySide6.QtGui import QGuiApplication from PySide6.QtWidgets import ( QCheckBox, QFileDialog, QGroupBox, QHBoxLayout, QLabel, QLineEdit, - QMessageBox, QPushButton, QSpinBox, QVBoxLayout, QWidget, + QMessageBox, QSpinBox, QVBoxLayout, QWidget, ) from je_auto_control.gui._i18n_helpers import TranslatableMixin @@ -52,12 +52,24 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._timer.start() def _build_layout(self) -> None: + # Start/stop/copy/export/import commands run from the Actions + # menu; the tab keeps only the config inputs and the status group. root = QVBoxLayout(self) root.addWidget(self._build_config_group()) - root.addLayout(self._build_button_row()) root.addWidget(self._build_status_group()) root.addStretch(1) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("rest_start", self._on_start), + ("rest_stop", self._on_stop), + ("rest_copy_url", self._on_copy_url), + ("rest_copy_token", self._on_copy_token), + ("rest_config_export", self._on_config_export), + ("rest_config_import", self._on_config_import), + ] + def _build_config_group(self) -> QGroupBox: group = self._tr(QGroupBox(), "rest_config_group") form = QVBoxLayout(group) @@ -75,29 +87,6 @@ def _build_config_group(self) -> QGroupBox: form.addWidget(self._audit_check) return group - def _build_button_row(self) -> QHBoxLayout: - row = QHBoxLayout() - start = self._tr(QPushButton(), "rest_start") - start.clicked.connect(self._on_start) - row.addWidget(start) - stop = self._tr(QPushButton(), "rest_stop") - stop.clicked.connect(self._on_stop) - row.addWidget(stop) - copy_url = self._tr(QPushButton(), "rest_copy_url") - copy_url.clicked.connect(self._on_copy_url) - row.addWidget(copy_url) - copy_token = self._tr(QPushButton(), "rest_copy_token") - copy_token.clicked.connect(self._on_copy_token) - row.addWidget(copy_token) - export_btn = self._tr(QPushButton(), "rest_config_export") - export_btn.clicked.connect(self._on_config_export) - row.addWidget(export_btn) - import_btn = self._tr(QPushButton(), "rest_config_import") - import_btn.clicked.connect(self._on_config_import) - row.addWidget(import_btn) - row.addStretch(1) - return row - def _on_config_export(self) -> None: path_str, _ = QFileDialog.getSaveFileName( self, _t("rest_config_export"), diff --git a/je_auto_control/gui/run_history_tab.py b/je_auto_control/gui/run_history_tab.py index baae3d75..4331892c 100644 --- a/je_auto_control/gui/run_history_tab.py +++ b/je_auto_control/gui/run_history_tab.py @@ -7,7 +7,7 @@ from PySide6.QtGui import QDesktopServices, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, QFrame, QHBoxLayout, QHeaderView, QLabel, - QMessageBox, QPushButton, QSplitter, QTableWidget, QTableWidgetItem, + QMessageBox, QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -129,20 +129,15 @@ def _apply_table_headers(self) -> None: ]) def _build_layout(self) -> None: + # Refresh/clear/open-artifact commands run from the Actions menu; + # the tab keeps only the filter, timeline, table, and preview. root = QVBoxLayout(self) top = QHBoxLayout() top.addWidget(self._tr(QLabel(), "rh_filter_label")) top.addWidget(self._filter) top.addStretch() - refresh_btn = self._tr(QPushButton(), "rh_refresh") - refresh_btn.clicked.connect(self._refresh) - top.addWidget(refresh_btn) - clear_btn = self._tr(QPushButton(), "rh_clear") - clear_btn.clicked.connect(self._on_clear) - top.addWidget(clear_btn) root.addLayout(top) - self._tr(QLabel(), "rh_timeline_heading") timeline_label = self._tr(QLabel(), "rh_timeline_heading") root.addWidget(timeline_label) root.addWidget(self._timeline) @@ -162,14 +157,16 @@ def _build_layout(self) -> None: self._table.cellDoubleClicked.connect(self._on_cell_double_clicked) self._table.itemSelectionChanged.connect(self._on_selection_changed) - open_row = QHBoxLayout() - self._open_artifact_btn = self._tr(QPushButton(), "rh_open_artifact") - self._open_artifact_btn.clicked.connect(self._open_selected_artifact) - open_row.addWidget(self._open_artifact_btn) - open_row.addStretch() - root.addLayout(open_row) root.addWidget(self._count_label) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("rh_refresh", self._refresh), + ("rh_clear", self._on_clear), + ("rh_open_artifact", self._open_selected_artifact), + ] + def _on_clear(self) -> None: reply = QMessageBox.question( self, _t("rh_clear"), _t("rh_confirm_clear"), diff --git a/je_auto_control/gui/scheduler_tab.py b/je_auto_control/gui/scheduler_tab.py index 3d0bb8e5..15b4b63a 100644 --- a/je_auto_control/gui/scheduler_tab.py +++ b/je_auto_control/gui/scheduler_tab.py @@ -4,7 +4,7 @@ from PySide6.QtCore import QTimer from PySide6.QtWidgets import ( QCheckBox, QFileDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, - QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from je_auto_control.gui._i18n_helpers import TranslatableMixin @@ -56,35 +56,29 @@ def retranslate(self) -> None: self._refresh_table() def _build_layout(self) -> None: + # Browse/add/remove/start/stop commands run from the Actions menu; + # the tab keeps only the inputs, the jobs table, and the status. root = QVBoxLayout(self) form = QHBoxLayout() form.addWidget(self._tr(QLabel(), "sch_script_label")) form.addWidget(self._path_input, stretch=1) - browse = self._tr(QPushButton(), "browse") - browse.clicked.connect(self._browse) - form.addWidget(browse) form.addWidget(self._tr(QLabel(), "sch_interval_label")) form.addWidget(self._interval_input) form.addWidget(self._repeat_check) - add_btn = self._tr(QPushButton(), "sch_add") - add_btn.clicked.connect(self._on_add) - form.addWidget(add_btn) root.addLayout(form) root.addWidget(self._table, stretch=1) + root.addWidget(self._status) - ctl = QHBoxLayout() - for key, handler in ( + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("browse", self._browse), + ("sch_add", self._on_add), ("sch_remove_selected", self._on_remove), ("sch_start", self._on_start), ("sch_stop", self._on_stop), - ): - btn = self._tr(QPushButton(), key) - btn.clicked.connect(handler) - ctl.addWidget(btn) - ctl.addStretch() - root.addLayout(ctl) - root.addWidget(self._status) + ] def _browse(self) -> None: path, _ = QFileDialog.getOpenFileName( diff --git a/je_auto_control/gui/self_healing_tab.py b/je_auto_control/gui/self_healing_tab.py index 727c9cec..686b4bc6 100644 --- a/je_auto_control/gui/self_healing_tab.py +++ b/je_auto_control/gui/self_healing_tab.py @@ -8,7 +8,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QCheckBox, QDoubleSpinBox, QFileDialog, QFormLayout, QGroupBox, - QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, + QLabel, QLineEdit, QMessageBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -53,9 +53,10 @@ def retranslate(self) -> None: # --- layout ---------------------------------------------------- def _build_layout(self) -> None: + # Browse/locate/click/refresh/clear commands run from the Actions + # menu; the tab keeps only the inputs, the log table, and the status. root = QVBoxLayout(self) root.addWidget(self._build_form_group()) - root.addLayout(self._build_log_controls()) root.addWidget(self._table, stretch=1) root.addWidget(self._status) self._apply_translations() @@ -64,42 +65,22 @@ def _build_layout(self) -> None: def _build_form_group(self) -> QGroupBox: group = QGroupBox() form = QFormLayout(group) - template_row = QHBoxLayout() - template_row.addWidget(self._template_input, stretch=1) - browse = QPushButton() - browse.setObjectName("self_heal_browse_btn") - browse.clicked.connect(self._on_browse_template) - template_row.addWidget(browse) - form.addRow(QLabel(), template_row) + form.addRow(QLabel(), self._template_input) form.addRow(QLabel(), self._description_input) form.addRow(QLabel(), self._threshold) form.addRow(QLabel(), self._click_check) - run_row = QHBoxLayout() - locate_btn = QPushButton() - locate_btn.setObjectName("self_heal_locate_btn") - locate_btn.clicked.connect(self._on_locate) - run_btn = QPushButton() - run_btn.setObjectName("self_heal_click_btn") - run_btn.clicked.connect(self._on_click) - run_row.addWidget(locate_btn) - run_row.addWidget(run_btn) - run_row.addStretch() - form.addRow(QLabel(), run_row) self._group_box = group return group - def _build_log_controls(self) -> QHBoxLayout: - row = QHBoxLayout() - refresh = QPushButton() - refresh.setObjectName("self_heal_refresh_btn") - refresh.clicked.connect(self.refresh_log) - clear = QPushButton() - clear.setObjectName("self_heal_clear_btn") - clear.clicked.connect(self._on_clear_log) - row.addWidget(refresh) - row.addWidget(clear) - row.addStretch() - return row + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("self_heal_browse", self._on_browse_template), + ("self_heal_locate_btn", self._on_locate), + ("self_heal_click_btn", self._on_click), + ("self_heal_refresh", self.refresh_log), + ("self_heal_clear", self._on_clear_log), + ] # --- translation ----------------------------------------------- @@ -113,25 +94,14 @@ def _apply_translations(self) -> None: labels = ( "self_heal_template_label", "self_heal_desc_label", "self_heal_threshold_label", "", - "", ) for row, key in enumerate(labels): item = layout.itemAt(row, QFormLayout.LabelRole) if item is not None and isinstance(item.widget(), QLabel): item.widget().setText(_t(key) if key else "") - self._set_button_text("self_heal_browse_btn", "self_heal_browse") - self._set_button_text("self_heal_locate_btn", "self_heal_locate_btn") - self._set_button_text("self_heal_click_btn", "self_heal_click_btn") - self._set_button_text("self_heal_refresh_btn", "self_heal_refresh") - self._set_button_text("self_heal_clear_btn", "self_heal_clear") headers = [_t(f"self_heal_col_{name}") for name in _COLUMNS] self._table.setHorizontalHeaderLabels(headers) - def _set_button_text(self, object_name: str, translation_key: str) -> None: - widget = self.findChild(QPushButton, object_name) - if widget is not None: - widget.setText(_t(translation_key)) - # --- actions --------------------------------------------------- def _on_browse_template(self) -> None: diff --git a/je_auto_control/gui/test_suite_tab.py b/je_auto_control/gui/test_suite_tab.py index c2a88096..930a109d 100644 --- a/je_auto_control/gui/test_suite_tab.py +++ b/je_auto_control/gui/test_suite_tab.py @@ -8,8 +8,8 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( - QAbstractItemView, QFileDialog, QHBoxLayout, QLabel, QListWidget, - QPlainTextEdit, QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, + QAbstractItemView, QFileDialog, QLabel, QListWidget, + QPlainTextEdit, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -58,37 +58,27 @@ def _apply_headers(self) -> None: self._table.setHorizontalHeaderLabels([_t(k) for k in _COLS]) def _build_layout(self) -> None: + # Load/run/export and quarantine commands run from the Actions + # menu; the tab keeps only the spec editor, tables, and summary. root = QVBoxLayout(self) root.addWidget(QLabel(_t("suite_spec_label"))) root.addWidget(self._spec, stretch=1) - controls = QHBoxLayout() - load_btn = self._tr(QPushButton(), "suite_load_file") - load_btn.clicked.connect(self._on_load_file) - run_btn = self._tr(QPushButton(), "suite_run") - run_btn.clicked.connect(self._on_run) - junit_btn = self._tr(QPushButton(), "suite_junit") - junit_btn.clicked.connect(self._on_junit) - allure_btn = self._tr(QPushButton(), "suite_allure") - allure_btn.clicked.connect(self._on_allure) - for btn in (load_btn, run_btn, junit_btn, allure_btn): - controls.addWidget(btn) - controls.addStretch() - root.addLayout(controls) root.addWidget(self._table, stretch=2) root.addWidget(self._summary) root.addWidget(QLabel(_t("suite_q_label"))) root.addWidget(self._quarantine) - qrow = QHBoxLayout() - auto_btn = self._tr(QPushButton(), "suite_q_auto") - auto_btn.clicked.connect(self._on_auto_quarantine) - remove_btn = self._tr(QPushButton(), "suite_q_remove") - remove_btn.clicked.connect(self._on_release_selected) - clear_btn = self._tr(QPushButton(), "suite_q_clear") - clear_btn.clicked.connect(self._on_clear_quarantine) - for btn in (auto_btn, remove_btn, clear_btn): - qrow.addWidget(btn) - qrow.addStretch() - root.addLayout(qrow) + + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("suite_load_file", self._on_load_file), + ("suite_run", self._on_run), + ("suite_junit", self._on_junit), + ("suite_allure", self._on_allure), + ("suite_q_auto", self._on_auto_quarantine), + ("suite_q_remove", self._on_release_selected), + ("suite_q_clear", self._on_clear_quarantine), + ] def _parse_spec(self): return json.loads(self._spec.toPlainText() or "{}") diff --git a/je_auto_control/gui/trace_replay_tab.py b/je_auto_control/gui/trace_replay_tab.py index eada9793..8e6f7bfc 100644 --- a/je_auto_control/gui/trace_replay_tab.py +++ b/je_auto_control/gui/trace_replay_tab.py @@ -12,7 +12,7 @@ from PySide6.QtCore import Qt from PySide6.QtGui import QPixmap from PySide6.QtWidgets import ( - QFileDialog, QHBoxLayout, QLabel, QPushButton, QSlider, + QFileDialog, QLabel, QSlider, QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -58,21 +58,9 @@ def retranslate(self) -> None: # --- layout --------------------------------------------------- def _build_layout(self) -> None: + # Open/first/prev/next/last commands run from the Actions menu; + # the tab keeps only the slider, frame, table, and status. root = QVBoxLayout(self) - controls = QHBoxLayout() - for key, slot in ( - ("trace_open_btn", self._on_open), - ("trace_first_btn", self._on_first), - ("trace_prev_btn", self._on_prev), - ("trace_next_btn", self._on_next), - ("trace_last_btn", self._on_last), - ): - btn = QPushButton() - btn.setObjectName(key) - btn.clicked.connect(slot) - controls.addWidget(btn) - controls.addStretch() - root.addLayout(controls) root.addWidget(self._slider) splitter = QSplitter(Qt.Horizontal) splitter.addWidget(self._frame_label) @@ -83,12 +71,17 @@ def _build_layout(self) -> None: root.addWidget(self._status) self._apply_translations() + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("trace_open_btn", self._on_open), + ("trace_first_btn", self._on_first), + ("trace_prev_btn", self._on_prev), + ("trace_next_btn", self._on_next), + ("trace_last_btn", self._on_last), + ] + def _apply_translations(self) -> None: - for key in ("trace_open_btn", "trace_first_btn", - "trace_prev_btn", "trace_next_btn", "trace_last_btn"): - widget = self.findChild(QPushButton, key) - if widget is not None: - widget.setText(_t(key)) headers = [_t(f"trace_col_{col}") for col in _ACTION_COLUMNS] self._actions_table.setHorizontalHeaderLabels(headers) diff --git a/je_auto_control/gui/triggers_tab.py b/je_auto_control/gui/triggers_tab.py index 74b254ca..8ac27904 100644 --- a/je_auto_control/gui/triggers_tab.py +++ b/je_auto_control/gui/triggers_tab.py @@ -88,38 +88,32 @@ def retranslate(self) -> None: self._refresh() def _build_layout(self) -> None: + # Browse/add/remove/combine/start/stop commands run from the Actions + # menu; the tab keeps only the inputs, the table, and the status. root = QVBoxLayout(self) form_top = QHBoxLayout() form_top.addWidget(self._tr(QLabel(), "tr_script_label")) form_top.addWidget(self._script_input, stretch=1) - browse = self._tr(QPushButton(), "browse") - browse.clicked.connect(self._browse_script) - form_top.addWidget(browse) form_top.addWidget(self._repeat_check) form_top.addWidget(self._tr(QLabel(), "tr_type_label")) form_top.addWidget(self._type_combo) - add_btn = self._tr(QPushButton(), "tr_add") - add_btn.clicked.connect(self._on_add) - form_top.addWidget(add_btn) root.addLayout(form_top) root.addWidget(self._stack) root.addWidget(self._table, stretch=1) + root.addWidget(self._status) - ctl = QHBoxLayout() - for key, handler in ( + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("browse", self._browse_script), + ("tr_add", self._on_add), ("tr_remove_selected", self._on_remove), ("tr_combine_all", lambda: self._on_combine("all")), ("tr_combine_any", lambda: self._on_combine("any")), ("tr_combine_seq", lambda: self._on_combine("sequence")), ("tr_start_engine", self._on_start), ("tr_stop_engine", self._on_stop), - ): - btn = self._tr(QPushButton(), key) - btn.clicked.connect(handler) - ctl.addWidget(btn) - ctl.addStretch() - root.addLayout(ctl) - root.addWidget(self._status) + ] def _build_image_form(self) -> dict: widget = QWidget() diff --git a/je_auto_control/gui/usb_browser_tab.py b/je_auto_control/gui/usb_browser_tab.py index b2b0d738..c0aaffee 100644 --- a/je_auto_control/gui/usb_browser_tab.py +++ b/je_auto_control/gui/usb_browser_tab.py @@ -24,7 +24,7 @@ from PySide6.QtCore import QObject, QThread, Signal from PySide6.QtWidgets import ( QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMessageBox, - QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, + QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from je_auto_control.gui._i18n_helpers import TranslatableMixin @@ -167,12 +167,20 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._apply_table_headers() def _build_layout(self) -> None: + # Fetch/open commands run from the Actions menu; the tab keeps + # only the target inputs, the status line, and the device table. root = QVBoxLayout(self) root.addWidget(self._build_target_group()) - root.addLayout(self._build_button_row()) root.addWidget(self._status_label) root.addWidget(self._table, stretch=1) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("usb_browser_fetch", self._on_fetch), + ("usb_browser_open", self._on_open_selected), + ] + def _build_target_group(self) -> QGroupBox: group = self._tr(QGroupBox(), "usb_browser_target_group") form = QVBoxLayout(group) @@ -186,17 +194,6 @@ def _build_target_group(self) -> QGroupBox: form.addLayout(token_row) return group - def _build_button_row(self) -> QHBoxLayout: - row = QHBoxLayout() - refresh = self._tr(QPushButton(), "usb_browser_fetch") - refresh.clicked.connect(self._on_fetch) - row.addWidget(refresh) - open_btn = self._tr(QPushButton(), "usb_browser_open") - open_btn.clicked.connect(self._on_open_selected) - row.addWidget(open_btn) - row.addStretch(1) - return row - def _apply_table_headers(self) -> None: self._table.setHorizontalHeaderLabels([ _t("usb_browser_col_vid"), diff --git a/je_auto_control/gui/usb_devices_tab.py b/je_auto_control/gui/usb_devices_tab.py index 3effce4a..308169b4 100644 --- a/je_auto_control/gui/usb_devices_tab.py +++ b/je_auto_control/gui/usb_devices_tab.py @@ -3,7 +3,7 @@ from PySide6.QtCore import QTimer from PySide6.QtWidgets import ( - QCheckBox, QHBoxLayout, QHeaderView, QLabel, QPushButton, QTableWidget, + QCheckBox, QHBoxLayout, QHeaderView, QLabel, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -44,6 +44,8 @@ def __init__(self, parent: Optional[QWidget] = None) -> None: self._refresh() def _build_layout(self) -> None: + # The refresh command runs from the Actions menu; the tab keeps + # only the backend labels, the auto-refresh toggle, and the table. root = QVBoxLayout(self) header = QHBoxLayout() header.addWidget(self._tr(QLabel(), "usb_backend_label")) @@ -51,14 +53,17 @@ def _build_layout(self) -> None: header.addStretch(1) self._tr(self._auto_check, "usb_auto_refresh") header.addWidget(self._auto_check) - refresh = self._tr(QPushButton(), "usb_refresh") - refresh.clicked.connect(self._refresh) - header.addWidget(refresh) root.addLayout(header) root.addWidget(self._error_label) root.addWidget(self._events_label) root.addWidget(self._table, stretch=1) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("usb_refresh", self._refresh), + ] + def _on_auto_toggled(self, on: bool) -> None: watcher = default_usb_watcher() if on: diff --git a/je_auto_control/gui/usb_passthrough_panel.py b/je_auto_control/gui/usb_passthrough_panel.py index 70404c40..16e64df4 100644 --- a/je_auto_control/gui/usb_passthrough_panel.py +++ b/je_auto_control/gui/usb_passthrough_panel.py @@ -22,7 +22,7 @@ from PySide6.QtCore import QObject, QThread, QTimer, Signal from PySide6.QtWidgets import ( QCheckBox, QComboBox, QFileDialog, QGroupBox, QHBoxLayout, QHeaderView, - QLabel, QLineEdit, QMessageBox, QPushButton, QTableWidget, + QLabel, QLineEdit, QMessageBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -116,49 +116,42 @@ def _default_loopback(self) -> UsbLoopback: # --- layout ------------------------------------------------------------ def _build_layout(self) -> None: + # Share/ACL/use/remote-fetch commands run from the Actions menu; + # the panel keeps only the badge, tables, inputs, and status. root = QHBoxLayout(self) root.setContentsMargins(16, 16, 16, 16) root.setSpacing(16) root.addWidget(self._build_host_section(), stretch=1) root.addWidget(self._build_viewer_section(), stretch=1) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("usb_share_enable", self._enable_sharing), + ("usb_share_disable", self._disable_sharing), + ("usb_share_refresh_local", self._refresh_local_devices), + ("usb_share_allow", self._allow_selected), + ("usb_share_block", self._block_selected), + ("usb_share_export_acl", self._export_acl), + ("usb_share_import_acl", self._import_acl), + ("usb_share_fetch_shared", self._list_shared), + ("usb_share_open_selected", self._open_selected), + ("usb_share_remote_fetch", self._fetch_remote), + ] + def _build_host_section(self) -> QWidget: group = QGroupBox() self._tr(group, "usb_share_host_group", setter="setTitle") layout = QVBoxLayout(group) layout.setSpacing(8) layout.addWidget(self._host_badge) - btn_row = QHBoxLayout() - enable_btn = self._tr(QPushButton(), "usb_share_enable") - enable_btn.clicked.connect(self._enable_sharing) - disable_btn = self._tr(QPushButton(), "usb_share_disable") - disable_btn.clicked.connect(self._disable_sharing) - btn_row.addWidget(enable_btn, stretch=1) - btn_row.addWidget(disable_btn, stretch=1) - layout.addLayout(btn_row) layout.addWidget(self._tr(QLabel(), "usb_share_local_devices")) layout.addWidget(self._local_table, stretch=1) acl_row = QHBoxLayout() - refresh_btn = self._tr(QPushButton(), "usb_share_refresh_local") - refresh_btn.clicked.connect(self._refresh_local_devices) - allow_btn = self._tr(QPushButton(), "usb_share_allow") - allow_btn.clicked.connect(lambda: self._set_policy(True)) - block_btn = self._tr(QPushButton(), "usb_share_block") - block_btn.clicked.connect(lambda: self._set_policy(False)) - acl_row.addWidget(refresh_btn) - acl_row.addWidget(allow_btn) - acl_row.addWidget(block_btn) self._tr(self._auto_check, "usb_share_auto_refresh") acl_row.addWidget(self._auto_check) + acl_row.addStretch(1) layout.addLayout(acl_row) - io_row = QHBoxLayout() - export_btn = self._tr(QPushButton(), "usb_share_export_acl") - export_btn.clicked.connect(self._export_acl) - import_btn = self._tr(QPushButton(), "usb_share_import_acl") - import_btn.clicked.connect(self._import_acl) - io_row.addWidget(export_btn) - io_row.addWidget(import_btn) - layout.addLayout(io_row) return group def _build_viewer_section(self) -> QWidget: @@ -174,14 +167,6 @@ def _build_viewer_section(self) -> QWidget: source_row.addWidget(self._source_combo, stretch=1) layout.addLayout(source_row) layout.addWidget(self._shared_table, stretch=1) - use_row = QHBoxLayout() - list_btn = self._tr(QPushButton(), "usb_share_fetch_shared") - list_btn.clicked.connect(self._list_shared) - open_btn = self._tr(QPushButton(), "usb_share_open_selected") - open_btn.clicked.connect(self._open_selected) - use_row.addWidget(list_btn) - use_row.addWidget(open_btn) - layout.addLayout(use_row) layout.addWidget(self._viewer_status) layout.addWidget(self._build_remote_box()) return group @@ -198,9 +183,6 @@ def _build_remote_box(self) -> QWidget: token_row.addWidget(self._tr(QLabel(), "usb_share_remote_token")) token_row.addWidget(self._remote_token, stretch=1) layout.addLayout(token_row) - fetch_btn = self._tr(QPushButton(), "usb_share_remote_fetch") - fetch_btn.clicked.connect(self._fetch_remote) - layout.addWidget(fetch_btn) return box def _apply_local_headers(self) -> None: @@ -301,6 +283,12 @@ def _poll_hotplug(self) -> None: self._last_seen_seq = events[-1]["seq"] self._refresh_local_devices() + def _allow_selected(self) -> None: + self._set_policy(True) + + def _block_selected(self) -> None: + self._set_policy(False) + def _set_policy(self, allow: bool) -> None: row = _selected_row(self._local_table) if row is None: diff --git a/je_auto_control/gui/vlm_tab.py b/je_auto_control/gui/vlm_tab.py index 82aa41d5..785c06b5 100644 --- a/je_auto_control/gui/vlm_tab.py +++ b/je_auto_control/gui/vlm_tab.py @@ -2,7 +2,7 @@ from typing import Optional from PySide6.QtWidgets import ( - QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, + QHBoxLayout, QLabel, QLineEdit, QMessageBox, QVBoxLayout, QWidget, ) @@ -42,6 +42,8 @@ def _apply_placeholders(self) -> None: self._model.setPlaceholderText(_t("vlm_model_placeholder")) def _build_layout(self) -> None: + # Locate/click commands run from the Actions menu; the tab keeps + # only the description/model inputs and the result labels. root = QVBoxLayout(self) desc_row = QHBoxLayout() desc_row.addWidget(self._tr(QLabel(), "vlm_desc_label")) @@ -51,19 +53,17 @@ def _build_layout(self) -> None: model_row.addWidget(self._tr(QLabel(), "vlm_model_label")) model_row.addWidget(self._model, stretch=1) root.addLayout(model_row) - btn_row = QHBoxLayout() - locate_btn = self._tr(QPushButton(), "vlm_locate") - locate_btn.clicked.connect(self._on_locate) - click_btn = self._tr(QPushButton(), "vlm_click") - click_btn.clicked.connect(self._on_click) - btn_row.addWidget(locate_btn) - btn_row.addWidget(click_btn) - btn_row.addStretch() - root.addLayout(btn_row) root.addWidget(self._last_result) root.addWidget(self._status) root.addStretch() + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("vlm_locate", self._on_locate), + ("vlm_click", self._on_click), + ] + def _collect_inputs(self): description = self._description.text().strip() if not description: diff --git a/je_auto_control/gui/webhooks_tab.py b/je_auto_control/gui/webhooks_tab.py index 131ff53b..2cc61c33 100644 --- a/je_auto_control/gui/webhooks_tab.py +++ b/je_auto_control/gui/webhooks_tab.py @@ -4,7 +4,7 @@ from PySide6.QtCore import QTimer, Qt from PySide6.QtWidgets import ( QAbstractItemView, QCheckBox, QFileDialog, QGroupBox, QHBoxLayout, - QHeaderView, QLabel, QLineEdit, QMessageBox, QPushButton, QSpinBox, + QHeaderView, QLabel, QLineEdit, QMessageBox, QSpinBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -73,6 +73,8 @@ def _apply_table_headers(self) -> None: ]) def _build_layout(self) -> None: + # Start/stop/browse/register/remove commands run from the Actions + # menu; the tab keeps only the inputs, the table, and the status. root = QVBoxLayout(self) server_box = self._tr(QGroupBox(), "wh_server_group") @@ -81,12 +83,6 @@ def _build_layout(self) -> None: server_layout.addWidget(self._host_input) server_layout.addWidget(self._tr(QLabel(), "wh_port_label")) server_layout.addWidget(self._port_input) - start_btn = self._tr(QPushButton(), "wh_start") - start_btn.clicked.connect(self._on_start) - server_layout.addWidget(start_btn) - stop_btn = self._tr(QPushButton(), "wh_stop") - stop_btn.clicked.connect(self._on_stop) - server_layout.addWidget(stop_btn) server_layout.addStretch() root.addWidget(server_box) root.addWidget(self._status_label) @@ -100,9 +96,6 @@ def _build_layout(self) -> None: script_row = QHBoxLayout() script_row.addWidget(self._tr(QLabel(), "wh_script_label")) script_row.addWidget(self._script_input) - browse_btn = self._tr(QPushButton(), "wh_browse") - browse_btn.clicked.connect(self._on_browse) - script_row.addWidget(browse_btn) add_layout.addLayout(script_row) method_row = QHBoxLayout() method_row.addWidget(self._tr(QLabel(), "wh_methods_label")) @@ -114,19 +107,20 @@ def _build_layout(self) -> None: token_row.addWidget(self._tr(QLabel(), "wh_token_label")) self._token_input.setPlaceholderText(_t("wh_token_placeholder")) token_row.addWidget(self._token_input) - register_btn = self._tr(QPushButton(), "wh_register") - register_btn.clicked.connect(self._on_register) - token_row.addWidget(register_btn) add_layout.addLayout(token_row) root.addWidget(add_box) root.addWidget(self._table, stretch=1) - action_row = QHBoxLayout() - remove_btn = self._tr(QPushButton(), "wh_remove") - remove_btn.clicked.connect(self._on_remove) - action_row.addWidget(remove_btn) - action_row.addStretch() - root.addLayout(action_row) + + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("wh_start", self._on_start), + ("wh_stop", self._on_stop), + ("wh_browse", self._on_browse), + ("wh_register", self._on_register), + ("wh_remove", self._on_remove), + ] def _on_start(self) -> None: host = self._host_input.text().strip() or "127.0.0.1" diff --git a/je_auto_control/gui/webrunner_tab.py b/je_auto_control/gui/webrunner_tab.py index ecd227c0..be0cda37 100644 --- a/je_auto_control/gui/webrunner_tab.py +++ b/je_auto_control/gui/webrunner_tab.py @@ -10,7 +10,7 @@ from PySide6.QtCore import Qt from PySide6.QtWidgets import ( QComboBox, QFileDialog, QFormLayout, QGroupBox, QHBoxLayout, QLabel, - QLineEdit, QListWidget, QMessageBox, QPushButton, QTextEdit, + QLineEdit, QListWidget, QMessageBox, QTextEdit, QVBoxLayout, QWidget, ) @@ -58,6 +58,8 @@ def retranslate(self) -> None: # --- layout ---------------------------------------------------- def _build_layout(self) -> None: + # Browse/open/quit/screenshot/run/refresh commands run from the + # Actions menu; the tab keeps only the inputs, list, and output. root = QVBoxLayout(self) root.addWidget(self._available_label) root.addWidget(self._build_convenience_group()) @@ -69,28 +71,23 @@ def _build_layout(self) -> None: root.addWidget(QLabel(_t("web_output_label"))) root.addWidget(self._output, stretch=2) + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("web_browse", self._on_browse_screenshot), + ("web_open_btn", self._on_open), + ("web_quit_btn", self._on_quit), + ("web_screenshot_btn", self._on_screenshot), + ("web_run_btn", self._on_run_freeform), + ("web_refresh_btn", self._on_refresh_commands), + ] + def _build_convenience_group(self) -> QGroupBox: group = QGroupBox(_t("web_convenience_title")) form = QFormLayout(group) form.addRow(QLabel(_t("web_url_label")), self._url_input) form.addRow(QLabel(_t("web_browser_label")), self._browser_input) - shot_row = QHBoxLayout() - shot_row.addWidget(self._screenshot_input, stretch=1) - browse = QPushButton(_t("web_browse")) - browse.clicked.connect(self._on_browse_screenshot) - shot_row.addWidget(browse) - form.addRow(QLabel(_t("web_screenshot_label")), shot_row) - actions_row = QHBoxLayout() - for label_key, slot in ( - ("web_open_btn", self._on_open), - ("web_quit_btn", self._on_quit), - ("web_screenshot_btn", self._on_screenshot), - ): - btn = QPushButton(_t(label_key)) - btn.clicked.connect(slot) - actions_row.addWidget(btn) - actions_row.addStretch() - form.addRow(QLabel(), actions_row) + form.addRow(QLabel(_t("web_screenshot_label")), self._screenshot_input) return group def _build_freeform_group(self) -> QGroupBox: @@ -102,15 +99,6 @@ def _build_freeform_group(self) -> QGroupBox: ) form.addRow(QLabel(_t("web_action_label")), self._action_input) form.addRow(QLabel(_t("web_params_label")), self._params_input) - run_row = QHBoxLayout() - run = QPushButton(_t("web_run_btn")) - run.clicked.connect(self._on_run_freeform) - refresh = QPushButton(_t("web_refresh_btn")) - refresh.clicked.connect(self._on_refresh_commands) - run_row.addWidget(run) - run_row.addWidget(refresh) - run_row.addStretch() - form.addRow(QLabel(), run_row) return group # --- availability --------------------------------------------- diff --git a/je_auto_control/gui/window_tab.py b/je_auto_control/gui/window_tab.py index 716fbdb0..d6b74353 100644 --- a/je_auto_control/gui/window_tab.py +++ b/je_auto_control/gui/window_tab.py @@ -3,7 +3,7 @@ from PySide6.QtCore import QTimer from PySide6.QtWidgets import ( - QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton, QTableWidget, + QHBoxLayout, QLabel, QLineEdit, QMessageBox, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) @@ -58,25 +58,22 @@ def retranslate(self) -> None: self._apply_status() def _build_layout(self) -> None: + # Refresh/focus/close commands run from the Actions menu; the tab + # keeps only the filter input, the window table, and the status. root = QVBoxLayout(self) top = QHBoxLayout() - refresh = self._tr(QPushButton(), "win_refresh") - refresh.clicked.connect(self.refresh) - top.addWidget(refresh) top.addWidget(self._filter, stretch=1) root.addLayout(top) root.addWidget(self._table, stretch=1) - actions = QHBoxLayout() - for key, handler in ( + root.addWidget(self._status) + + def menu_actions(self) -> list: + """Expose tab commands to the window-level Actions menu.""" + return [ + ("win_refresh", self.refresh), ("win_focus_selected", self._on_focus), ("win_close_selected", self._on_close), - ): - btn = self._tr(QPushButton(), key) - btn.clicked.connect(handler) - actions.addWidget(btn) - actions.addStretch() - root.addLayout(actions) - root.addWidget(self._status) + ] def refresh(self) -> None: try: From 567d7e3eff48a3a2d78fbf0eec9381f433b2b77e Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 2 Jul 2026 19:54:30 +0800 Subject: [PATCH 3/7] Guard the Actions-menu tab contract with a headless test Every registered tab must surface its commands through the Actions menu (registry actions or the menu_actions() hook); a missing hook would silently strand a tab with no reachable commands now that the in-tab buttons are gone. --- .../headless/test_actions_menu_gui.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 test/unit_test/headless/test_actions_menu_gui.py diff --git a/test/unit_test/headless/test_actions_menu_gui.py b/test/unit_test/headless/test_actions_menu_gui.py new file mode 100644 index 00000000..13534d1d --- /dev/null +++ b/test/unit_test/headless/test_actions_menu_gui.py @@ -0,0 +1,68 @@ +"""GUI smoke tests for the window-level Actions menu tab hooks.""" +import os + +import pytest + +pytest.importorskip("PySide6.QtWidgets") + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +from PySide6.QtWidgets import QApplication # noqa: E402 + +from je_auto_control.gui.main_widget import AutoControlGUIWidget # noqa: E402 + +# Interactive panels that intentionally keep their own in-tab layouts +# instead of exposing commands through the Actions menu. +_MENU_EXEMPT_TABS = {"script_builder", "remote_desktop"} + + +@pytest.fixture(scope="module") +def app(): + return QApplication.instance() or QApplication([]) + + +@pytest.fixture(scope="module") +def widget(app): + return AutoControlGUIWidget() + + +def _entry_actions(entry): + if entry.actions: + return list(entry.actions) + provider = getattr(entry.widget, "menu_actions", None) + return list(provider()) if callable(provider) else [] + + +def test_every_tab_exposes_menu_actions(widget): + missing = [ + entry.key for entry in widget._tab_entries + if entry.key not in _MENU_EXEMPT_TABS and not _entry_actions(entry) + ] + assert missing == [] + + +def test_menu_actions_are_key_handler_pairs(widget): + for entry in widget._tab_entries: + for action in _entry_actions(entry): + label_key, handler = action + assert isinstance(label_key, str) and label_key + assert callable(handler) + + +def test_current_tab_menu_actions_follows_active_tab(widget): + record_entry = next( + entry for entry in widget._tab_entries if entry.key == "record" + ) + widget.tabs.setCurrentWidget(record_entry.widget) + assert widget.current_tab_menu_actions() == list(record_entry.actions) + + +def test_hook_tab_actions_reach_the_menu(widget): + widget.show_tab("variables") + entry = next( + entry for entry in widget._tab_entries if entry.key == "variables" + ) + widget.tabs.setCurrentWidget(entry.widget) + actions = widget.current_tab_menu_actions() + assert actions == entry.widget.menu_actions() + assert actions, "hook-based tab should surface its actions" From 3af936f04673ab5c1e119f279f5cf8e9abf08ec1 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 2 Jul 2026 19:55:10 +0800 Subject: [PATCH 4/7] Document the Actions-menu GUI redesign in WHATS_NEW --- WHATS_NEW.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/WHATS_NEW.md b/WHATS_NEW.md index ce781384..53d55541 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,13 @@ # What's New — AutoControl +## What's new (2026-07-02) + +### Menu-Driven GUI: the Actions Menu Replaces In-Tab Buttons + +Every tab's commands now live in one predictable place. The window menu bar gains a dynamic **Actions** menu that rebuilds for the active tab; tabs keep only their inputs, tables, and result/status views instead of rows of buttons. + +- **Window-level Actions menu**: core tabs declare their commands at registration; feature tabs expose a `menu_actions()` hook returning `(label_key, handler)` pairs. 46 of 48 registered tabs now surface their commands this way — Script Builder and Remote Desktop intentionally keep their interactive panel layouts, and the menu shows a placeholder there. Buttons a window-level menu cannot replace stay in place (per-page browse buttons inside stacked trigger forms, the visibility-toggled data-source browse button, stateful auto-refresh checkboxes). A headless regression test guards the contract so no tab can silently lose its commands. + ## What's new (2026-06-26) ### Trial and Force Action Modes (Playwright-style) From 9d55895e3eb516ce4f1b75e7a71adef281f976f8 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 2 Jul 2026 19:57:25 +0800 Subject: [PATCH 5/7] Remove orphaned prof_disable translation key The profiler enable button no longer swaps its text, so the key lost its last reference. --- je_auto_control/gui/language_wrapper/english.py | 1 - je_auto_control/gui/language_wrapper/japanese.py | 1 - je_auto_control/gui/language_wrapper/simplified_chinese.py | 1 - je_auto_control/gui/language_wrapper/traditional_chinese.py | 1 - 4 files changed, 4 deletions(-) diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index bfc915ed..77be9539 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -935,7 +935,6 @@ # Profiler tab "prof_enable": "Enable profiler", - "prof_disable": "Disable profiler", "prof_reset": "Reset stats", "prof_refresh": "Refresh", "prof_running": "Profiler is recording.", diff --git a/je_auto_control/gui/language_wrapper/japanese.py b/je_auto_control/gui/language_wrapper/japanese.py index 2b6d5f30..1f683e96 100644 --- a/je_auto_control/gui/language_wrapper/japanese.py +++ b/je_auto_control/gui/language_wrapper/japanese.py @@ -824,7 +824,6 @@ # Profiler tab "prof_enable": "プロファイラを有効化", - "prof_disable": "プロファイラを無効化", "prof_reset": "統計をリセット", "prof_refresh": "更新", "prof_running": "プロファイラ計測中。", diff --git a/je_auto_control/gui/language_wrapper/simplified_chinese.py b/je_auto_control/gui/language_wrapper/simplified_chinese.py index b58b62b4..94c5c5a3 100644 --- a/je_auto_control/gui/language_wrapper/simplified_chinese.py +++ b/je_auto_control/gui/language_wrapper/simplified_chinese.py @@ -813,7 +813,6 @@ # Profiler tab "prof_enable": "启用性能分析", - "prof_disable": "停用性能分析", "prof_reset": "清除统计", "prof_refresh": "刷新", "prof_running": "性能分析中。", diff --git a/je_auto_control/gui/language_wrapper/traditional_chinese.py b/je_auto_control/gui/language_wrapper/traditional_chinese.py index f948a91d..af9881b0 100644 --- a/je_auto_control/gui/language_wrapper/traditional_chinese.py +++ b/je_auto_control/gui/language_wrapper/traditional_chinese.py @@ -814,7 +814,6 @@ # Profiler tab "prof_enable": "啟用效能分析", - "prof_disable": "停用效能分析", "prof_reset": "清除統計", "prof_refresh": "重新整理", "prof_running": "效能分析中。", From fa1a44e8d46ec093687c3d913cef71352d9522a6 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 3 Jul 2026 10:27:32 +0800 Subject: [PATCH 6/7] Quarantine the Actions-menu contract probe in a subprocess Building the full tab set creates Qt widgets and native helper threads whose teardown aborted the host interpreter long after the module finished (CI: "Fatal Python error: Aborted" inside test_admin_client). Run the probe in a child process and assert on its JSON report so the rest of the headless suite stays deterministic. --- .../headless/test_actions_menu_gui.py | 135 ++++++++++++------ 1 file changed, 95 insertions(+), 40 deletions(-) diff --git a/test/unit_test/headless/test_actions_menu_gui.py b/test/unit_test/headless/test_actions_menu_gui.py index 13534d1d..968cb64d 100644 --- a/test/unit_test/headless/test_actions_menu_gui.py +++ b/test/unit_test/headless/test_actions_menu_gui.py @@ -1,68 +1,123 @@ -"""GUI smoke tests for the window-level Actions menu tab hooks.""" +"""GUI smoke tests for the window-level Actions menu tab hooks. + +The probe runs in a subprocess: building the full tab set creates Qt +widgets and native helper threads whose teardown can abort the host +interpreter long after this module finishes (seen in CI as +``Fatal Python error: Aborted`` inside a later, unrelated test file). +Quarantining the Qt lifetime in a child process keeps the rest of the +headless suite deterministic. +""" +import json import os +import subprocess +import sys import pytest pytest.importorskip("PySide6.QtWidgets") +_PROBE = r""" +import json +import os +import sys + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -from PySide6.QtWidgets import QApplication # noqa: E402 +from PySide6.QtWidgets import QApplication -from je_auto_control.gui.main_widget import AutoControlGUIWidget # noqa: E402 +from je_auto_control.gui.main_widget import AutoControlGUIWidget # Interactive panels that intentionally keep their own in-tab layouts # instead of exposing commands through the Actions menu. -_MENU_EXEMPT_TABS = {"script_builder", "remote_desktop"} +MENU_EXEMPT_TABS = {"script_builder", "remote_desktop"} - -@pytest.fixture(scope="module") -def app(): - return QApplication.instance() or QApplication([]) - - -@pytest.fixture(scope="module") -def widget(app): - return AutoControlGUIWidget() +app = QApplication.instance() or QApplication([]) +widget = AutoControlGUIWidget() -def _entry_actions(entry): +def entry_actions(entry): if entry.actions: return list(entry.actions) provider = getattr(entry.widget, "menu_actions", None) return list(provider()) if callable(provider) else [] -def test_every_tab_exposes_menu_actions(widget): - missing = [ - entry.key for entry in widget._tab_entries - if entry.key not in _MENU_EXEMPT_TABS and not _entry_actions(entry) - ] - assert missing == [] +report = { + "missing_actions": [], + "bad_pairs": [], + "record_menu_matches": False, + "variables_menu_matches": False, + "variables_has_actions": False, +} + +for entry in widget._tab_entries: + actions = entry_actions(entry) + if entry.key not in MENU_EXEMPT_TABS and not actions: + report["missing_actions"].append(entry.key) + for action in actions: + if ( + not isinstance(action, (tuple, list)) or len(action) != 2 + or not isinstance(action[0], str) or not action[0] + or not callable(action[1]) + ): + report["bad_pairs"].append(entry.key) + +record_entry = next(e for e in widget._tab_entries if e.key == "record") +widget.tabs.setCurrentWidget(record_entry.widget) +report["record_menu_matches"] = ( + widget.current_tab_menu_actions() == list(record_entry.actions) +) + +widget.show_tab("variables") +variables_entry = next(e for e in widget._tab_entries if e.key == "variables") +widget.tabs.setCurrentWidget(variables_entry.widget) +menu_actions = widget.current_tab_menu_actions() +report["variables_menu_matches"] = ( + menu_actions == variables_entry.widget.menu_actions() +) +report["variables_has_actions"] = bool(menu_actions) + +sys.stdout.write(json.dumps(report)) +sys.stdout.flush() +# Skip Qt/native-thread teardown entirely: some tabs start helper threads +# at construction and interpreter shutdown can abort. The report is +# already on stdout, so a hard exit is the safe end for this probe. +os._exit(0) +""" -def test_menu_actions_are_key_handler_pairs(widget): - for entry in widget._tab_entries: - for action in _entry_actions(entry): - label_key, handler = action - assert isinstance(label_key, str) and label_key - assert callable(handler) +@pytest.fixture(scope="module") +def report(): + env = dict(os.environ) + env.setdefault("QT_QPA_PLATFORM", "offscreen") + # subprocess spawned with [sys.executable, ...] — known interpreter, + # fixed argv list, no shell=True, no user input. + completed = subprocess.run( # nosec B603 # nosemgrep + [sys.executable, "-c", _PROBE], + capture_output=True, text=True, check=False, timeout=180, env=env, + ) + if completed.returncode != 0: + pytest.fail( + "Actions-menu probe subprocess failed " + f"(exit {completed.returncode}):\n{completed.stderr}" + ) + return json.loads(completed.stdout) -def test_current_tab_menu_actions_follows_active_tab(widget): - record_entry = next( - entry for entry in widget._tab_entries if entry.key == "record" - ) - widget.tabs.setCurrentWidget(record_entry.widget) - assert widget.current_tab_menu_actions() == list(record_entry.actions) +def test_every_tab_exposes_menu_actions(report): + assert report["missing_actions"] == [] + + +def test_menu_actions_are_key_handler_pairs(report): + assert report["bad_pairs"] == [] + + +def test_current_tab_menu_actions_follows_active_tab(report): + assert report["record_menu_matches"] -def test_hook_tab_actions_reach_the_menu(widget): - widget.show_tab("variables") - entry = next( - entry for entry in widget._tab_entries if entry.key == "variables" +def test_hook_tab_actions_reach_the_menu(report): + assert report["variables_menu_matches"] + assert report["variables_has_actions"], ( + "hook-based tab should surface its actions" ) - widget.tabs.setCurrentWidget(entry.widget) - actions = widget.current_tab_menu_actions() - assert actions == entry.widget.menu_actions() - assert actions, "hook-based tab should surface its actions" From 124a4a9e41e06af229954c80385c4268baa07ac5 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 3 Jul 2026 10:27:32 +0800 Subject: [PATCH 7/7] Restore line and text-region detection under OpenCV 5 HoughLinesP now returns (N, 4) instead of (N, 1, 4), so indexing the middle axis unpacked scalars; reshape tolerates both layouts. MSER's diversity pruning got strict enough to drop every region on flat UI-style frames, so relax min_diversity progressively before reporting that a frame has no text. --- .../utils/edge_lines/edge_lines.py | 5 +++-- .../utils/text_regions/text_regions.py | 21 ++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/je_auto_control/utils/edge_lines/edge_lines.py b/je_auto_control/utils/edge_lines/edge_lines.py index 0682cc31..92308751 100644 --- a/je_auto_control/utils/edge_lines/edge_lines.py +++ b/je_auto_control/utils/edge_lines/edge_lines.py @@ -52,9 +52,10 @@ def find_lines(haystack: Optional[ImageSource] = None, *, segments = _segments(_haystack_gray(haystack, region), int(min_length), int(max_gap)) out: List[Dict[str, Any]] = [] - if segments is None: + if segments is None or len(segments) == 0: return out - for x1, y1, x2, y2 in segments[:, 0]: + # OpenCV 4 returns (N, 1, 4); OpenCV 5 flattened it to (N, 4). + for x1, y1, x2, y2 in segments.reshape(-1, 4): angle = math.degrees(math.atan2(int(y2) - int(y1), int(x2) - int(x1))) kind = _orientation(angle) if orientation not in ("any", kind): diff --git a/je_auto_control/utils/text_regions/text_regions.py b/je_auto_control/utils/text_regions/text_regions.py index fe78af2e..8ba949cb 100644 --- a/je_auto_control/utils/text_regions/text_regions.py +++ b/je_auto_control/utils/text_regions/text_regions.py @@ -40,11 +40,30 @@ def _accept(rect: Rect, shape, min_area: int, max_area: Optional[int], return aspect <= max_aspect +# OpenCV 5 tightened MSER's diversity pruning: flat-background UI scenes +# that OpenCV 4 segmented fine now yield zero regions at the default +# min_diversity (0.2). Relax the pruning progressively before concluding +# the frame has no text. +_MSER_PARAM_LADDER = ({}, {"min_diversity": 0.01}, + {"delta": 1, "min_diversity": 0.0}) + + +def _detect_regions(gray): + """Return MSER point sets, relaxing diversity pruning if none found.""" + import cv2 + regions = () + for params in _MSER_PARAM_LADDER: + regions, _bboxes = cv2.MSER_create(**params).detectRegions(gray) + if len(regions): + break + return regions + + def _filtered_boxes(gray, min_area: int, max_area: Optional[int], max_aspect: float) -> List[Rect]: """Return de-duplicated MSER bounding boxes passing the size / aspect filters.""" import cv2 - regions, _bboxes = cv2.MSER_create().detectRegions(gray) + regions = _detect_regions(gray) out: List[Rect] = [] seen = set() for points in regions: