From d87f388efd36c9772499f434a43e5d15f64a3ef2 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Thu, 8 Oct 2026 22:01:52 +0800 Subject: [PATCH 1/3] Give the GUI a searchable navigation panel, token-based dark and light themes, and tabs built on first open Forty-five of the 48 tabs could only be reached through View > Tabs > category, the window built every one of them before showing three, and its look was a third-party sheet that could not be adjusted. The panel lists every feature and searches it (Ctrl+K), the theme comes from one set of tokens, and a tab's module is imported when the tab is opened. The qt_material guard test goes with the import it guarded, so the main-window tests it kept skipped in CI now run there. --- CHANGELOG.md | 17 + Progress.md | 18 +- README.md | 2 +- README/README_zh-CN.md | 2 +- README/README_zh-TW.md | 2 +- architecture.md | 7 +- architecture_explore.md | 23 +- .../doc/new_features/v223_features_doc.rst | 35 +- .../Zh/doc/new_features/v223_features_doc.rst | 28 +- docs/updates/2026-10.md | 14 + docs/updates/README.md | 3 +- je_auto_control/gui/__init__.py | 2 + .../gui/language_wrapper/english.py | 8 + .../gui/language_wrapper/japanese.py | 8 + .../language_wrapper/simplified_chinese.py | 8 + .../language_wrapper/traditional_chinese.py | 8 + je_auto_control/gui/main_widget.py | 395 ++++++++---------- je_auto_control/gui/main_window.py | 114 ++++- je_auto_control/gui/navigation.py | 200 +++++++++ je_auto_control/gui/tab_registry.py | 123 ++++++ je_auto_control/gui/theme.py | 178 ++++++++ .../headless/test_gui_lazy_registry.py | 154 +++++++ .../headless/test_gui_slots_menu_audit.py | 2 - .../headless/test_gui_tab_lifecycle_audit.py | 5 +- .../headless/test_gui_workspace_navigation.py | 226 ++++++++++ .../headless/test_qt_material_guard.py | 64 --- .../headless/test_r3_gui_main_window.py | 10 +- 27 files changed, 1309 insertions(+), 347 deletions(-) create mode 100644 je_auto_control/gui/navigation.py create mode 100644 je_auto_control/gui/tab_registry.py create mode 100644 je_auto_control/gui/theme.py create mode 100644 test/unit_test/headless/test_gui_lazy_registry.py create mode 100644 test/unit_test/headless/test_gui_workspace_navigation.py delete mode 100644 test/unit_test/headless/test_qt_material_guard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 062ccdf3e..f78dd9825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ### Added +- GUI: a navigation panel on the left of the main window lists every tab by + category, with a search box (`Ctrl+K`; `Ctrl+B` hides the panel). + `AutoControlGUIWidget.activate_tab(key)` opens a tab or brings it to the + front, and `current_tab_key()` names the tab on screen. +- GUI: **View → Theme** switches between a dark and a light theme, both built + from the tokens in `gui/theme.py` (`AutoControlGUIUI.set_theme(name)`). - AI-agent documentation and a dedicated `AI.md` explain computer-use positioning, MCP aliases, safe tool selection, and OpenAI integration. - `AC_run_agent` now uses a focused computer-use allow-list by default instead of exposing the full `AC_*` command catalogue to the model. - `write_secret(secret)` / `AC_write_secret` (`secret`): type a password or @@ -155,6 +161,17 @@ it shipped into a `## [x.y.z] - date` section of their own; the tag's ### Changed +- GUI tabs are built the first time they are opened. `AutoControlGUIWidget` + registers all 48 tabs from `gui/tab_registry.py` but constructs only the + three it opens on and its own forms; `list_registered_tabs()` builds + nothing, and reading `entry.widget` on a `_tab_entries` row builds that tab. + An embedder that relied on every tab existing after construction (a tab's + timer or listener started at start-up) has to open the tab first. +- The main window no longer imports `qt_material`; it styles itself from + `gui/theme.py`. The `[gui]` extra still lists `qt-material` for now. +- GUI text size "Auto" is 10 / 11 / 13 pt by screen height (was 12 / 14 / 16), + and the default window is 1280×800 (was 1000×760). The font family is the + platform's UI font instead of Lato. - `je_auto_control_dev`, the dev-channel package, declares what `je_auto_control` declares: the same pinned dependencies and platform markers (`defusedxml`, `cryptography` and the `opencv-python` bound are new diff --git a/Progress.md b/Progress.md index 276069c77..7741dff80 100644 --- a/Progress.md +++ b/Progress.md @@ -8,7 +8,21 @@ `utils/{config_sync,remote_desktop,mcp_server,self_healing,codegen,executor}/` 與型別/文件驗證。 核准設計:[跨平台自動化與 GUI 改版](docs/superpowers/specs/2026-10-02-platform-gui-modernization-design.md)。 實作計畫:[分階段交付計畫](docs/superpowers/plans/2026-10-02-modernization-index.md),待審閱。 -現有 `[Answer]` 決策沿用;產品實作尚未開始。 +現有 `[Answer]` 決策沿用。 + +`WIP` — 計畫 F(GUI):F1 的延遲分頁註冊與 F2 的導覽/搜尋/主題已交付(U-20261008-02),其餘各子計畫尚未開始。F 還缺: + +- **窄視窗的內容是被擠壓而不是可捲動**:`gui/main_widget.py` 給 `QTabWidget` 明確的最小尺寸讓視窗能縮到 640×420, + 但分頁內容沒有包進 `QScrollArea`;包進去會改變 `tabs.indexOf(entry.widget)` 這個 PyBreeze 與測試都在用的關係,要一起設計。 +- **主題與面板狀態不會記住**:`AutoControlGUIUI.set_theme`、導覽面板的顯示與寬度、字級都只活在當次執行; + 要用 `QSettings` 存,並讓測試不寫到使用者的設定。 +- **`qt-material` 還在 `[gui]` extra**:`gui/main_window.py` 已不匯入它。移除要同時改 `pyproject.toml`、`dev.toml`、 + `requirements.txt`、`uv.lock` 與 mypy 的 override,並先確認 PyBreeze 沒有靠這個 extra 取得它。 +- **分頁的關閉鈕是 Fusion 內建圖示**:計畫規定主題不新增點陣圖相依,換圖示要用 Qt 內建向量或既有資產。 +- **Remote Desktop 與 Script Builder 仍在啟動時建立**(預設開啟),啟動時間 2.6–2.7 秒裡大半是它們與門面匯入。 +- **F1 的 `TabRegistry.open/close` 介面與 `close` 釋放訂閱**:現在關閉分頁只是從分頁列移除,widget 留著。 +- **F3**(共用 worker、取消、關閉時不碰已銷毀物件、`webrtc_panel.py` 拆分)與 **F4**(啟動/記憶體基準、mixed-DPI、 + 功能對等測試)尚未開始。 **只記未完成的事。** 完成的工作記在 [docs/updates/](docs/updates/README.md)(每月一個批次檔, 索引與查詢指令在它的 README),相容性變更寫進 [CHANGELOG.md](CHANGELOG.md);完成的項目 @@ -507,7 +521,7 @@ be at 2x if on a Retina screen」,`scale_down=True` 只在帶 `bbox` 時生效 ## `test_usb_acl_prompt.py` 讓 Python 3.10 的 headless 測試間歇 segfault -`TODO` — `test/unit_test/headless/test_usb_acl_prompt.py::test_bridge_remember_persists_acl_rule` 在 `coverage run -m pytest` 下讓行程 SIGSEGV(exit 139),整個 `pytest-headless` job 因此失敗:2026-09-26 連續三次 AutoControl Code Quality(ubuntu-22.04/3.10),2026-09-30 一次(macos-14/3.10);同一次其他版本都過,之後的 run 又過,所以是間歇的。原因還沒查:先在 3.10 開 `faulthandler` 重跑這一支,看崩在哪個原生呼叫。 +`TODO` — `test/unit_test/headless/test_usb_acl_prompt.py::test_bridge_remember_persists_acl_rule` 在 `coverage run -m pytest` 下讓行程 SIGSEGV(exit 139),整個 `pytest-headless` job 因此失敗:2026-09-26 連續三次 AutoControl Code Quality(ubuntu-22.04/3.10),2026-09-30 一次(macos-14/3.10),2026-10-08 一次(ubuntu-22.04/3.10,PR #501,重跑該 job 後通過);同一次其他版本都過,之後的 run 又過,所以是間歇的。原因還沒查:先在 3.10 開 `faulthandler` 重跑這一支,看崩在哪個原生呼叫。 --- diff --git a/README.md b/README.md index ec9c28d20..ba7d30e84 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ Optional extras, installed only when you need them: | Extra | Enables | |---|---| -| `gui` | PySide6 desktop application (48 tabs) | +| `gui` | PySide6 desktop application (48 tabs): a searchable navigation panel (`Ctrl+K`) lists every feature by category, tabs are built the first time they are opened, and **View → Theme** switches dark / light | | `webrtc` | WebRTC remote desktop, USB passthrough (`aiortc`, `av`) | | `signaling` | Standalone signaling / rendezvous server (`fastapi`, `uvicorn`) | | `discovery` | mDNS / Zeroconf LAN host discovery | diff --git a/README/README_zh-CN.md b/README/README_zh-CN.md index a8eb3e953..429fdd30b 100644 --- a/README/README_zh-CN.md +++ b/README/README_zh-CN.md @@ -115,7 +115,7 @@ pip install je_auto_control[gui] # 加上 PySide6 桌面应用 | Extra | 启用的功能 | |---|---| -| `gui` | PySide6 桌面应用(48 个标签页) | +| `gui` | PySide6 桌面应用(48 个标签页):左侧可搜索的导航面板(`Ctrl+K`)按分类列出全部功能,标签页在第一次打开时才创建,**View → Theme** 切换深色/浅色 | | `webrtc` | WebRTC 远程桌面、USB 直通(`aiortc`、`av`) | | `signaling` | 独立的信令/rendezvous 服务器(`fastapi`、`uvicorn`) | | `discovery` | mDNS / Zeroconf 局域网主机发现 | diff --git a/README/README_zh-TW.md b/README/README_zh-TW.md index 88674e276..b4e4520fc 100644 --- a/README/README_zh-TW.md +++ b/README/README_zh-TW.md @@ -114,7 +114,7 @@ pip install je_auto_control[gui] # 加上 PySide6 桌面應用程式 | Extra | 啟用的功能 | |---|---| -| `gui` | PySide6 桌面應用程式(48 個分頁) | +| `gui` | PySide6 桌面應用程式(48 個分頁):左側可搜尋的導覽面板(`Ctrl+K`)依分類列出全部功能,分頁在第一次開啟時才建立,**View → Theme** 切換深色/淺色 | | `webrtc` | WebRTC 遠端桌面、USB 直通(`aiortc`、`av`) | | `signaling` | 獨立的訊令/rendezvous 伺服器(`fastapi`、`uvicorn`) | | `discovery` | mDNS / Zeroconf 區網主機探索 | diff --git a/architecture.md b/architecture.md index c4ef2ffe6..627fbc98b 100644 --- a/architecture.md +++ b/architecture.md @@ -29,7 +29,7 @@ entry points → execution core (`utils/executor/`) → headless capabilities (` | `je_auto_control/wrapper/` | Platform-neutral API (`auto_control_mouse/keyboard/screen/image/record/window.py`); `platform_wrapper.py` picks the backend; `backend_contract.py` types the seam; `window_backends/`. | | `je_auto_control/{windows,osx,linux_with_x11,linux_wayland}/` | Desktop OS backends; only the running OS's backend is imported. | | `je_auto_control/{android,ios}/` | Mobile device control (adb / uiautomator2, WebDriverAgent). | -| `je_auto_control/gui/` | Optional PySide6 GUI (`[gui]` extra): `main_window.py`, tab registry `main_widget.py`, `script_builder/`, `remote_desktop/`, `language_wrapper/`. | +| `je_auto_control/gui/` | Optional PySide6 GUI (`[gui]` extra): `main_window.py` (menus, navigation dock, theme), `main_widget.py` (the tabbed workspace), the tab table `tab_registry.py` (tabs are built on first open), `navigation.py`, `theme.py`, `script_builder/`, `remote_desktop/`, `language_wrapper/`. | | `autocontrol-lsp/` | Separate distribution: language server for `AC_*` action JSON, plus a `vscode/` client. | | `test/` | `unit_test/headless/` (CI gate), `unit_test/flow_control/`, `integrated_test/`, `gui_test/`, `manual_test/`, `verify/`. | | `docs/` | Sphinx docs, `API_LIFECYCLE.md`, `CAPABILITY_MATRIX.md`. | @@ -108,8 +108,9 @@ wrapper/auto_control_record.record → OS listener (e.g. windows/record/win32_in `_handlers_scheduling.py`, `_handlers_remote.py`, `_handlers_locators.py`, `_handlers_operations.py`, `_handlers_qa.py`, `_handlers_executor_bridge.py` (a three-line delegation to an executor function), or `_handlers.py` for data, text and the WebRunner bridge. -6. GUI: thin widget in `gui/`, registered in `gui/main_widget.py` (`_add_tab`) with commands exposed through - `menu_actions()`; strings in every `gui/language_wrapper/*.py` catalogue. +6. GUI: thin widget in `gui/`, registered by one `TabSpec` row in `gui/tab_registry.py` (module and class name, so + it is imported only when opened) with commands exposed through `menu_actions()`; strings in every + `gui/language_wrapper/*.py` catalogue. 7. Headless test in `test/unit_test/headless/`. 8. Update `architecture_explore.md` (and `README.md` + `README/` translations if a quoted count changes), then run `python test/unit_test/headless/test_doc_line_counts.py --fix`. Regenerate the typed stub with diff --git a/architecture_explore.md b/architecture_explore.md index de1263480..e466e94c7 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -19,8 +19,8 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | -| Python 模組總數(含周邊子專案) | 1,065 | -| 程式碼總行數 | 157,741 | +| Python 模組總數(含周邊子專案) | 1,068 | +| 程式碼總行數 | 158,285 | | `je_auto_control/utils/` 子套件數 | 310 | | `AC_*` 動作指令數(`known_commands()` 實測) | 778 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -880,9 +880,12 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,PySide6 + qt-mate | 模組 | 行數 | 職責 | | --- | ---: | --- | -| `gui/__init__.py` | 23 | `start_autocontrol_gui()`:**唯一**會延遲匯入 PySide6 的地方,維持頂層套件 Qt-free。 | -| `main_window.py` | 301 | `QMainWindow`:選單列(File/Actions/View/…)、可關閉分頁、即時語言切換、字級預設、qt-material 主題。分頁分為 core/editing/detection/automation/system 五類。 | -| `main_widget.py` | 437 | 擁有 `QTabWidget`,註冊 48 個分頁,並暴露 show/hide/list API 給選單列。核心分頁在註冊時直接宣告 `(label_key, handler)` 動作對;分頁本體都在下列 mixin。 | +| `gui/__init__.py` | 25 | `start_autocontrol_gui()`:**唯一**會延遲匯入 PySide6 的地方,維持頂層套件 Qt-free。 | +| `main_window.py` | 379 | `QMainWindow`:選單列(File/Actions/View/…)、左側導覽面板 dock(`Ctrl+K` 搜尋、`Ctrl+B` 收合)、即時語言切換、字級預設、深色/淺色主題(`theme.py` 的 token,不再用 qt-material)。分頁分為 core/editing/detection/automation/system 五類。 | +| `main_widget.py` | 368 | 工作區:擁有 `QTabWidget`,依 `tab_registry.TAB_SPECS` 註冊 48 個分頁,並暴露 show/hide/activate/list API 給選單列與導覽面板。只有預設開啟的三個分頁與自己的 mixin 表單在啟動時建立,其餘第一次開啟才匯入模組、建立 widget。核心分頁在 `_own_tab_builders` 宣告 `(label_key, handler)` 動作對。 | +| `tab_registry.py` | 123 | 分頁表:每個分頁一筆 `TabSpec`(鍵、標題鍵、分類、模組與類別名),`TabEntry` 在第一次存取 `widget` 時才呼叫 factory。不匯入 Qt。 | +| `navigation.py` | 200 | `NavigationPanel`:搜尋框 + 依分類的功能樹,列出每個已註冊分頁(開啟中的以粗體標示),只回報被選的鍵,開啟分頁仍由視窗負責。 | +| `theme.py` | 178 | 設計 token(`ThemeTokens`:顏色、圓角、間距、字族)、深色與淺色兩組值、由 token 產生的樣式表與對應的 `QPalette`;不載入圖檔或字型檔。 | | `_auto_click_tab.py` | 291 | 自動點擊分頁的 mixin 建構器。 | | `_screenshot_tab.py` | 137 | 截圖/取像素分頁 mixin。 | | `_image_detect_tab.py` | 115 | 影像偵測分頁 mixin。 | @@ -894,11 +897,11 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,PySide6 + qt-mate | `_screen_geometry.py` | 52 | Qt 邏輯座標與截圖用的原生像素互轉:`native_region()`、`screen_at_native()`、`logical_point()`(每個螢幕的左上角在兩者相同,螢幕內依 device pixel ratio 縮放)。區域選取與主機端標註覆蓋層都用它。 | | `_daemon_thread.py` | 79 | `DaemonThread`:`QThread` 的替代品,保留遠端桌面 worker 用到的介面(`start`/`run`/`isRunning`/`wait`/`requestInterruption`/`started`/`finished`),但 `run()` 跑在 daemon `threading.Thread` 上,刪除物件或程式結束都不會銷毀執行中的執行緒。 | | `_worker_thread.py` | 216 | `start_worker()`:在 daemon `threading.Thread` 上執行 `QObject` worker 的 `run()`(沒有 `QThread` 可被銷毀),並經由分頁擁有的中繼物件回報結果(回呼一律在 GUI 執行緒;worker 沒處理的例外也送到 `on_fail`);worker 留在模組登錄表直到 GUI 執行緒看到它結束,回傳 `WorkerHandle`(`isRunning()`);程式結束時先呼叫 worker 的 `request_stop()`,最多等 10 秒,仍在跑的隨行程結束。 | -| `language_wrapper/` | 5,031 | 四語系字典(英/日/簡中/繁中)+ `multi_language_wrapper` 執行期切換器與監聽註冊表。 | +| `language_wrapper/` | 5,063 | 四語系字典(英/日/簡中/繁中)+ `multi_language_wrapper` 執行期切換器與監聽註冊表。 | | `selector/` | 216 | 拖曳選取螢幕區域的半透明全螢幕覆蓋層與樣板裁切工具(互動式,但都有對應的程式化 API)。 | > **分頁指令一律走 Actions 選單**:分頁本身只放輸入、表格與結果檢視,指令由視窗層選單暴露。 -> 核心分頁在 `main_widget.py` 註冊時宣告動作;功能分頁實作 `menu_actions()`(目前 40 個檔案有此 hook)。 +> 核心分頁在 `main_widget.py` 的 `_own_tab_builders` 宣告動作;功能分頁實作 `menu_actions()`(目前 40 個檔案有此 hook)。 > `test/unit_test/headless/test_actions_menu_gui.py` 會守住這個契約——沒有動作宣告的新分頁會讓 CI 失敗。 #### 48 個分頁 @@ -1005,7 +1008,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,PySide6 + qt-mate | **新平台後端** | 新增 `je_auto_control//` 實作 backend 介面,並在 `wrapper/platform_wrapper.py` 加一個分支 | 所有 wrapper 模組與上層 | | **新 `AC_*` 指令** | 在 `utils/` 寫無頭實作 → 加進 `Executor.event_dict` → 加進 `gui/script_builder/command_schema.py` | executor 分派邏輯本身 | | **執行期外掛指令** | `add_command_to_executor({"AC_x": fn})`,或用 `utils/plugin_loader`(掃描目錄)/`utils/plugin_sdk`(entry points) | 核心程式碼 | -| **新 GUI 分頁** | 在 `gui/` 新增 widget(只做 UI 翻譯)→ 在 `main_widget.py` `_add_tab` 註冊 → 提供 `menu_actions()` | 主視窗選單建構邏輯 | +| **新 GUI 分頁** | 在 `gui/` 新增 widget(只做 UI 翻譯)→ 在 `gui/tab_registry.py` 的 `TAB_SPECS` 加一筆 `TabSpec` → 提供 `menu_actions()` | 主視窗選單建構邏輯 | | **新 OCR/VLM/LLM/a11y 後端** | 在對應 `backends/` 實作 base 協定 | 呼叫端 | | **新報表格式** | 仿 `generate_report/` 既有三者的骨架新增產生器 | 執行紀錄收集 | | **新 MCP 工具** | 在 `mcp_server/tools/_factories.py` 加工廠、`_handlers.py` 加 adapter(QA 主題加在 `_handlers_qa.py`) | 傳輸層 | @@ -1076,7 +1079,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | -| `gui/` | 95 | 27,853 | +| `gui/` | 98 | 28,397 | | `utils/mcp_server/` | 35 | 18,898 | | `utils/remote_desktop/` | 56 | 13,014 | | `utils/executor/` | 8 | 9,606 | @@ -1097,5 +1100,5 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 852 | | 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 682 | 57,156 | -| **總計** | **1,059** | **157,676** | +| **總計** | **1,062** | **158,220** | diff --git a/docs/source/Eng/doc/new_features/v223_features_doc.rst b/docs/source/Eng/doc/new_features/v223_features_doc.rst index ef5264f69..8b64c4fc4 100644 --- a/docs/source/Eng/doc/new_features/v223_features_doc.rst +++ b/docs/source/Eng/doc/new_features/v223_features_doc.rst @@ -25,6 +25,36 @@ replace stay in place: per-page browse buttons inside stacked trigger forms, the visibility-toggled data-source browse button, and stateful auto-refresh checkboxes. +The navigation panel +-------------------- + +Every registered tab is listed on the left of the window, grouped by the same +five categories, whether it is open or not; open tabs are shown in bold. +Click a feature to open it (or bring it to the front). The search box filters +the list as you type — by title, by key (``usb_devices``) or by category — and +**Return** opens the first match. ``Ctrl+K`` (**View → Search Features...**) +puts the cursor in the search box from anywhere, and ``Ctrl+B`` +(**View → Navigation Panel**) hides or shows the panel. + +A tab is built the first time it is opened: the window starts with the three +default tabs and the forms the main widget owns, and imports the module of +any other tab only when you open it. + +The navigation panel +-------------------- + +Every registered tab is listed on the left of the window, grouped by the same +five categories, whether it is open or not; open tabs are shown in bold. +Click a feature to open it (or bring it to the front). The search box filters +the list as you type — by title, by key (``usb_devices``) or by category — and +**Return** opens the first match. ``Ctrl+K`` (**View → Search Features...**) +puts the cursor in the search box from anywhere, and ``Ctrl+B`` +(**View → Navigation Panel**) hides or shows the panel. + +A tab is built the first time it is opened: the window starts with the three +default tabs and the forms the main widget owns, and imports the module of +any other tab only when you open it. + The View menu ------------- @@ -33,8 +63,11 @@ The View menu default layout opens with just Record, Script Builder, and Remote Desktop; everything else is one menu click away. Tabs are closable — closing one is the same as unchecking it in the View menu. +* **View → Theme** switches between the dark and the light theme. Both come + from one set of design tokens in ``gui/theme.py``; the window no longer + uses ``qt-material``. * **View → Text Size** offers auto (screen-height based) and preset font - sizes applied live. + sizes applied live, on top of the active theme. The contract test ----------------- diff --git a/docs/source/Zh/doc/new_features/v223_features_doc.rst b/docs/source/Zh/doc/new_features/v223_features_doc.rst index 4b872c04b..6f0e01764 100644 --- a/docs/source/Zh/doc/new_features/v223_features_doc.rst +++ b/docs/source/Zh/doc/new_features/v223_features_doc.rst @@ -22,6 +22,30 @@ Actions 選單 逐頁瀏覽按鈕、隨可見性切換的資料來源瀏覽按鈕,以及有狀態的自動更新 核取方塊。 +導覽面板 +-------- + +視窗左側依同樣五個分類列出每一個已註冊的分頁,不論是否已開啟;已開啟的以粗體 +顯示。點一下就開啟該功能(已開啟則切到最前面)。搜尋框會隨輸入過濾清單——比對 +標題、鍵名(``usb_devices``)或分類——按 **Return** 開啟第一個符合的項目。 +``Ctrl+K``(**View → Search Features...**)從任何地方把游標移到搜尋框, +``Ctrl+B``(**View → Navigation Panel**)隱藏或顯示面板。 + +分頁在第一次開啟時才建立:視窗啟動時只有三個預設分頁與主元件自己的表單,其他 +分頁的模組要到開啟時才匯入。 + +導覽面板 +-------- + +視窗左側依同樣五個分類列出每一個已註冊的分頁,不論是否已開啟;已開啟的以粗體 +顯示。點一下就開啟該功能(已開啟則切到最前面)。搜尋框會隨輸入過濾清單——比對 +標題、鍵名(``usb_devices``)或分類——按 **Return** 開啟第一個符合的項目。 +``Ctrl+K``(**View → Search Features...**)從任何地方把游標移到搜尋框, +``Ctrl+B``(**View → Navigation Panel**)隱藏或顯示面板。 + +分頁在第一次開啟時才建立:視窗啟動時只有三個預設分頁與主元件自己的表單,其他 +分頁的模組要到開啟時才匯入。 + View 選單 --------- @@ -29,7 +53,9 @@ View 選單 顯示或隱藏任一已註冊分頁。預設版面只開啟錄製、Script Builder 與遠端 桌面;其餘分頁一個選單點擊即可叫出。分頁可關閉——關閉等同於在 View 選單取消勾選。 -* **View → Text Size** 提供自動(依螢幕高度)與預設字級,即時套用。 +* **View → Theme** 在深色與淺色主題之間切換。兩者都出自 ``gui/theme.py`` 的同一組 + 設計 token;視窗不再使用 ``qt-material``。 +* **View → Text Size** 提供自動(依螢幕高度)與預設字級,即時套用在目前的主題上。 契約測試 -------- diff --git a/docs/updates/2026-10.md b/docs/updates/2026-10.md index fa95e543c..1ff1082b7 100644 --- a/docs/updates/2026-10.md +++ b/docs/updates/2026-10.md @@ -204,3 +204,17 @@ Jeffrey_RPA kept its own `KEY_ALIASES` (40 friendly names) and `_EXTRA_KEY_CODES - **Tests**: `test/unit_test/headless/test_cross_project_contracts.py` +3 (real child processes), `test/unit_test/headless/test_report_path.py`. - **Docs**: `README.md`, `README/README_zh-TW.md`, `README/README_zh-CN.md`, `architecture.md`. - **Files**: `je_auto_control/__main__.py`, `je_auto_control/utils/generate_report/` (`report_path.py` new; three writers), the tests and docs above. + +## U-20261008-02 · 2026-10-08 · GUI: navigation panel with search, dark and light themes from tokens, tabs built on first open · #feature #gui #F1 #F2 + +- **What**: the first two tasks of plan F (`docs/superpowers/plans/2026-10-02-f-gui.md`), without the parts that wait on plans A–E. + - **Navigation** (`gui/navigation.py`, docked in `main_window.py`): every registered tab is listed on the left under its category, open or not, open ones in bold. The search box filters by title, key or category, every word, any case; Return opens the first match; no match shows a message instead of an empty list. `Ctrl+K` focuses the search and reveals a hidden panel, `Ctrl+B` toggles the panel. Both shortcuts belong to the window, not to a menu, so rebuilding the menu bar on a language switch does not register them again. Before, a tab that was not one of the three default ones could only be reached through View → Tabs → category. + - **Theme** (`gui/theme.py`): `ThemeTokens` (12 colours, radius, spacing, font family), `DARK` and `LIGHT`, `build_stylesheet(tokens)` and a matching `QPalette`, so what Qt draws itself follows the theme. View → Theme switches; the text-size rule is still appended to the theme's sheet. `main_window.py` no longer imports `qt_material`. The launcher sets the Fusion style. + - **Lazy tabs** (`gui/tab_registry.py`): 48 `TabSpec` rows (key, title key, category, module, class). `TabEntry.widget` calls its factory on first access. `main_widget.py` builds the three default tabs and its own six mixin forms (Record is both, so eight widgets) and nothing else; `list_registered_tabs()`, `retranslate()`, `sync_engine_tabs()` and `hide_tab()` build nothing. `_add_tab` still takes a ready widget. `main_widget.py` went from 437 to 368 lines and no longer imports the tab modules at the top. + - The tab widget has an explicit minimum size, so the window shrinks to 640×420; before, the Script Builder's minimum width kept it at 1064 px with the panel docked. +- **Result / numbers** (Windows 11, Python 3.14, offscreen, `AutoControlGUIWidget()` from a cold interpreter, four runs each): widgets built at start 48 → 8; `je_auto_control` modules imported 892 → 844; time to a built workspace 2.90–3.08 s → 2.63–2.74 s. Most of what is left is the facade import and the Remote Desktop and Script Builder tabs, which open by default. +- **Tests**: `test_gui_lazy_registry.py` (50: the table without Qt, each lazy class name read from its source, and a child-process probe of what is built at start, after listing, and on open / hide / reopen) and `test_gui_workspace_navigation.py` (17: the style sheet, WCAG contrast of both themes, the panel's search, and a child-process probe of the real window). `test_qt_material_guard.py` is removed with the import it guarded, and the three `importorskip("qt_material")` lines with it, so `test_r3_gui_main_window.py`, the tabs-menu leak test and the window-listener test now run in CI. The window-listener probe opens the Presence tab before counting listeners, because the tab is no longer built at start. +- **Checked by eye**: both themes and the 640×420 window rendered with `WA_DontShowOnScreen` on the Windows platform plugin (real fonts, nothing shown on the desktop). +- **Docs**: the three READMEs (extras table), `docs/source/{Eng,Zh}/doc/new_features/v223_features_doc.rst`, `architecture.md` §2 and §5, `architecture_explore.md` (GUI file table, extension points), `CHANGELOG.md`. +- **Files**: `je_auto_control/gui/{tab_registry,navigation,theme}.py` (new), `je_auto_control/gui/{main_widget,main_window}.py`, the four `gui/language_wrapper/` catalogues (8 keys each), the tests above. +- **Open items**: in `Progress.md` › 跨平台與 GUI 全面改版 — page content is squeezed, not scrolled, in a narrow window; the theme and panel state are not remembered between runs; `qt-material` is still in the `[gui]` extra; tab close buttons are Fusion's; F3 (workers, cancel, close) and F4 (benchmarks, parity) have not started. diff --git a/docs/updates/README.md b/docs/updates/README.md index da7d0ceaa..73e4ad061 100644 --- a/docs/updates/README.md +++ b/docs/updates/README.md @@ -58,6 +58,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | ID | Date | Title | Tags | Batch | |---|---|---|---|---| +| U-20261008-02 | 2026-10-08 | GUI: navigation panel with search, dark and light themes from tokens, tabs built on first open | #feature #gui #F1 #F2 | [2026-10](2026-10.md) | | U-20261008-01 | 2026-10-08 | The legacy CLI flags exit 1 when an action failed; reports go below TestPioneer's artifact directory | #feature #cli #report #testpioneer | [2026-10](2026-10.md) | | U-20261006-01 | 2026-10-06 | Make AutoControl discoverable and safer for AI agents | #feature #ai #mcp #docs | [2026-10](2026-10.md) | | U-20261001-10 | 2026-10-01 | The publish jobs install hash-locked build tooling and build with the locked setuptools | #release #ci #security #X-13 | [2026-10](2026-10.md) | @@ -348,7 +349,7 @@ In the same commit: delete the item from `Progress.md`, add a `#done` entry here | File | Period | Entries | |---|---|---:| -| [2026-10.md](2026-10.md) | 2026-10 | 12 | +| [2026-10.md](2026-10.md) | 2026-10 | 13 | | [2026-09-e.md](2026-09-e.md) | 2026-09 | 7 | | [2026-09-d.md](2026-09-d.md) | 2026-09 | 55 | | [2026-09-c.md](2026-09-c.md) | 2026-09 | 38 | diff --git a/je_auto_control/gui/__init__.py b/je_auto_control/gui/__init__.py index 26e60a0b6..931f3e884 100644 --- a/je_auto_control/gui/__init__.py +++ b/je_auto_control/gui/__init__.py @@ -16,8 +16,10 @@ def start_autocontrol_gui() -> None: from PySide6.QtWidgets import QApplication from je_auto_control.gui.main_window import AutoControlGUIUI + from je_auto_control.gui.theme import prepare_application app = QApplication(sys.argv) + prepare_application(app) window = AutoControlGUIUI() window.show() sys.exit(app.exec()) diff --git a/je_auto_control/gui/language_wrapper/english.py b/je_auto_control/gui/language_wrapper/english.py index 72c2fcff4..a5ccc74ca 100644 --- a/je_auto_control/gui/language_wrapper/english.py +++ b/je_auto_control/gui/language_wrapper/english.py @@ -1318,6 +1318,14 @@ "menu_view_text_large": "Large (14pt)", "menu_view_text_xlarge": "Extra Large (16pt)", "menu_view_text_xxlarge": "Huge (20pt)", + "menu_view_search": "Search Features...", + "menu_view_sidebar": "Navigation Panel", + "menu_view_theme": "Theme", + "menu_view_theme_dark": "Dark", + "menu_view_theme_light": "Light", + "nav_search_placeholder": "Search features (Ctrl+K)", + "nav_no_results": "No feature matches this search.", + "nav_open_hint": "Open in the workspace", "menu_tools": "Tools", "menu_tools_start_hotkeys": "Start hotkey daemon", "menu_tools_start_scheduler": "Start scheduler", diff --git a/je_auto_control/gui/language_wrapper/japanese.py b/je_auto_control/gui/language_wrapper/japanese.py index b55ba6cc3..5e7faea6b 100644 --- a/je_auto_control/gui/language_wrapper/japanese.py +++ b/je_auto_control/gui/language_wrapper/japanese.py @@ -1205,6 +1205,14 @@ "menu_view_text_large": "大 (14pt)", "menu_view_text_xlarge": "特大 (16pt)", "menu_view_text_xxlarge": "超大 (20pt)", + "menu_view_search": "機能を検索...", + "menu_view_sidebar": "ナビゲーションパネル", + "menu_view_theme": "テーマ", + "menu_view_theme_dark": "ダーク", + "menu_view_theme_light": "ライト", + "nav_search_placeholder": "機能を検索 (Ctrl+K)", + "nav_no_results": "一致する機能がありません。", + "nav_open_hint": "ワークスペースで開いています", "menu_tools": "ツール", "menu_tools_start_hotkeys": "ホットキーデーモン開始", "menu_tools_start_scheduler": "スケジューラー開始", diff --git a/je_auto_control/gui/language_wrapper/simplified_chinese.py b/je_auto_control/gui/language_wrapper/simplified_chinese.py index 7f08c1c07..424def77e 100644 --- a/je_auto_control/gui/language_wrapper/simplified_chinese.py +++ b/je_auto_control/gui/language_wrapper/simplified_chinese.py @@ -1184,6 +1184,14 @@ "menu_view_text_large": "大 (14pt)", "menu_view_text_xlarge": "特大 (16pt)", "menu_view_text_xxlarge": "超大 (20pt)", + "menu_view_search": "搜索功能...", + "menu_view_sidebar": "导航面板", + "menu_view_theme": "主题", + "menu_view_theme_dark": "深色", + "menu_view_theme_light": "浅色", + "nav_search_placeholder": "搜索功能 (Ctrl+K)", + "nav_no_results": "没有匹配的功能。", + "nav_open_hint": "已在工作区打开", "menu_tools": "工具", "menu_tools_start_hotkeys": "启动热键守护进程", "menu_tools_start_scheduler": "启动调度器", diff --git a/je_auto_control/gui/language_wrapper/traditional_chinese.py b/je_auto_control/gui/language_wrapper/traditional_chinese.py index 9f9a645ed..db29e507d 100644 --- a/je_auto_control/gui/language_wrapper/traditional_chinese.py +++ b/je_auto_control/gui/language_wrapper/traditional_chinese.py @@ -1185,6 +1185,14 @@ "menu_view_text_large": "大 (14pt)", "menu_view_text_xlarge": "特大 (16pt)", "menu_view_text_xxlarge": "超大 (20pt)", + "menu_view_search": "搜尋功能...", + "menu_view_sidebar": "導覽面板", + "menu_view_theme": "佈景主題", + "menu_view_theme_dark": "深色", + "menu_view_theme_light": "淺色", + "nav_search_placeholder": "搜尋功能 (Ctrl+K)", + "nav_no_results": "沒有符合的功能。", + "nav_open_hint": "已在工作區開啟", "menu_tools": "工具", "menu_tools_start_hotkeys": "啟動熱鍵服務", "menu_tools_start_scheduler": "啟動排程器", diff --git a/je_auto_control/gui/main_widget.py b/je_auto_control/gui/main_widget.py index b543cf55c..4ee4e4ac0 100644 --- a/je_auto_control/gui/main_widget.py +++ b/je_auto_control/gui/main_widget.py @@ -1,6 +1,6 @@ +"""Workspace widget: owns the tab bar and the registry of every tab it can open.""" import json -from dataclasses import dataclass -from typing import Optional +from typing import Any, Dict, List, Optional, Union from PySide6.QtCore import QTimer, Signal, QObject from PySide6.QtGui import QKeyEvent, Qt @@ -12,80 +12,25 @@ from je_auto_control.gui._i18n_helpers import TranslatableMixin from je_auto_control.gui._image_detect_tab import ImageDetectTabMixin from je_auto_control.gui._record_tab import RecordTabMixin +from je_auto_control.gui._report_tab import ReportTabMixin from je_auto_control.gui._screenshot_tab import ScreenshotTabMixin from je_auto_control.gui._script_tab import ScriptTabMixin -from je_auto_control.gui.accessibility_tab import AccessibilityTab -from je_auto_control.gui.assertions_tab import AssertionsTab -from je_auto_control.gui.data_source_tab import DataSourceTab -from je_auto_control.gui.flakiness_tab import FlakinessTab -from je_auto_control.gui.test_suite_tab import TestSuiteTab -from je_auto_control.gui.a11y_audit_tab import A11yAuditTab -from je_auto_control.gui.device_matrix_tab import DeviceMatrixTab -from je_auto_control.gui.media_checks_tab import MediaChecksTab -from je_auto_control.gui.computer_use_tab import ComputerUseTab -from je_auto_control.gui.chatops_tab import ChatOpsTab -from je_auto_control.gui.dag_tab import DagTab -from je_auto_control.gui.trace_replay_tab import TraceReplayTab -from je_auto_control.gui._report_tab import ReportTabMixin -from je_auto_control.gui.hotkeys_tab import HotkeysTab from je_auto_control.gui.language_wrapper.multi_language_wrapper import language_wrapper -from je_auto_control.gui.live_hud_tab import LiveHUDTab -from je_auto_control.gui.llm_planner_tab import LLMPlannerTab -from je_auto_control.gui.ocr_tab import OCRReaderTab -from je_auto_control.gui.plugins_tab import PluginsTab -from je_auto_control.gui.presence_tab import PresenceTab -from je_auto_control.gui.profiler_tab import ProfilerTab -from je_auto_control.gui.secrets_tab import SecretsTab -from je_auto_control.gui.admin_console_tab import AdminConsoleTab -from je_auto_control.gui.audit_log_tab import AuditLogTab -from je_auto_control.gui.diagnostics_tab import DiagnosticsTab -from je_auto_control.gui.inspector_tab import InspectorTab -from je_auto_control.gui.recording_editor_tab import RecordingEditorTab -from je_auto_control.gui.usb_browser_tab import UsbBrowserTab -from je_auto_control.gui.usb_devices_tab import UsbDevicesTab -from je_auto_control.gui.usb_passthrough_panel import UsbPassthroughPanel -# Remote desktop relies on the optional `webrtc` extra (aiortc + PyAV). -# Importing it eagerly would break embedders (e.g. PyBreeze) that install -# je_auto_control without the extra; fall back to a placeholder tab that -# tells the user how to enable it. -try: - from je_auto_control.gui.remote_desktop_tab import RemoteDesktopTab - _REMOTE_DESKTOP_IMPORT_ERROR: Optional[ImportError] = None -except ImportError as _remote_desktop_error: - RemoteDesktopTab = None # type: ignore[assignment,misc] # reason: name is a class or None - _REMOTE_DESKTOP_IMPORT_ERROR = _remote_desktop_error -from je_auto_control.gui.rest_api_tab import RestApiTab -from je_auto_control.gui.run_history_tab import RunHistoryTab -from je_auto_control.gui.scheduler_tab import SchedulerTab -from je_auto_control.gui.flow_editor import FlowEditorTab -from je_auto_control.gui.script_builder import ScriptBuilderTab -from je_auto_control.gui.self_healing_tab import SelfHealingTab -from je_auto_control.gui.triggers_tab import TriggersTab -from je_auto_control.gui.webhooks_tab import WebhooksTab -from je_auto_control.gui.email_triggers_tab import EmailTriggersTab -from je_auto_control.gui.variables_tab import VariablesTab -from je_auto_control.gui.vlm_tab import VLMTab -from je_auto_control.gui.webrunner_tab import WebRunnerTab -from je_auto_control.gui.window_tab import WindowManagerTab +from je_auto_control.gui.tab_registry import ( + TAB_SPECS, MenuActions, TabEntry, WidgetFactory, lazy_factory, +) from je_auto_control.utils.exception.exceptions import AutoControlException from je_auto_control.utils.json.json_file import read_action_json +# Kept under its old name: tests and embedders read ``_tab_entries`` rows as this. +_TabEntry = TabEntry + class _WorkerSignals(QObject): finished = Signal(str) error = Signal(str) -@dataclass -class _TabEntry: - key: str - title_key: str - widget: QWidget - category: str = "core" - default_visible: bool = False - actions: tuple = () - - # ============================================================================= # Main Widget # ============================================================================= @@ -103,154 +48,29 @@ def __init__(self, parent=None): super().__init__(parent) self._tr_init() layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) - self._tab_entries: list = [] + self._tab_entries: List[TabEntry] = [] self.tabs = QTabWidget() self.tabs.setTabsClosable(True) + self.tabs.setDocumentMode(True) + self.tabs.setUsesScrollButtons(True) + # Without this the widest page's minimum size is the window's: the + # Script Builder alone kept the window from going under ~860 px. + self.tabs.setMinimumSize(360, 240) self.tabs.tabCloseRequested.connect(self._on_tab_close_requested) - # Default UI keeps only the last three of the previously-visible - # tabs (record / script_builder / remote_desktop) so the launcher - # opens on a focused capture+script+remote workflow. The earlier - # 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", 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", 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", 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, 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", 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(), - category="editing") - self._add_tab("secrets", "tab_secrets", SecretsTab(), - category="editing") - self._add_tab("vlm", "tab_vlm", VLMTab(), - category="detection") - self._add_tab("self_healing", "tab_self_healing", SelfHealingTab(), - category="detection") - self._add_tab("ocr_reader", "tab_ocr_reader", OCRReaderTab(), - category="detection") - self._add_tab("accessibility", "tab_accessibility", AccessibilityTab(), - category="detection") - self._add_tab("live_hud", "tab_live_hud", LiveHUDTab(), - category="detection") - self._add_tab("llm_planner", "tab_llm_planner", LLMPlannerTab(), - category="detection") - self._add_tab("computer_use", "tab_computer_use", ComputerUseTab(), - category="detection") - self._add_tab("scheduler", "tab_scheduler", SchedulerTab(), - category="automation") - self._add_tab("hotkeys", "tab_hotkeys", HotkeysTab(), - category="automation") - self._add_tab("triggers", "tab_triggers", TriggersTab(), - category="automation") - self._add_tab("webhooks", "tab_webhooks", WebhooksTab(), - category="automation") - self._add_tab("email_triggers", "tab_email_triggers", - EmailTriggersTab(), category="automation") - self._add_tab("test_suite", "tab_test_suite", TestSuiteTab(), - category="core") - self._add_tab("assertions", "tab_assertions", AssertionsTab(), - category="core") - self._add_tab("data_source", "tab_data_source", DataSourceTab(), - category="core") - self._add_tab("flakiness", "tab_flakiness", FlakinessTab(), - category="system") - self._add_tab("a11y_audit", "tab_a11y_audit", A11yAuditTab(), - category="core") - self._add_tab("device_matrix", "tab_device_matrix", DeviceMatrixTab(), - category="core") - self._add_tab("media_checks", "tab_media_checks", MediaChecksTab(), - category="core") - self._add_tab("run_history", "tab_run_history", RunHistoryTab(), - category="automation") - self._add_tab("profiler", "tab_profiler", ProfilerTab(), - category="automation") - self._add_tab("window_manager", "tab_window_manager", WindowManagerTab(), - category="system") - self._add_tab("plugins", "tab_plugins", PluginsTab(), - category="system") - self._add_tab("webrunner", "tab_webrunner", WebRunnerTab(), - category="automation") - self._add_tab("dag_runner", "tab_dag_runner", DagTab(), - category="automation") - self._add_tab("chatops", "tab_chatops", ChatOpsTab(), - category="automation") - self._add_tab("trace_replay", "tab_trace_replay", TraceReplayTab(), - category="automation") - self._add_tab( - "remote_desktop", "tab_remote_desktop", - self._build_remote_desktop_tab(), - category="system", default_visible=True, - ) - self._add_tab("presence", "tab_presence", PresenceTab(), - category="system") - self._add_tab("rest_api", "tab_rest_api", RestApiTab(), - category="system") - self._add_tab("admin_console", "tab_admin_console", AdminConsoleTab(), - category="system") - self._add_tab("audit_log", "tab_audit_log", AuditLogTab(), - category="system") - self._add_tab("inspector", "tab_inspector", InspectorTab(), - category="system") - self._add_tab("usb_devices", "tab_usb_devices", UsbDevicesTab(), - category="system") - self._add_tab("usb_browser", "tab_usb_browser", UsbBrowserTab(), - category="system") - self._add_tab("usb_share", "tab_usb_share", UsbPassthroughPanel(), - category="system") - self._add_tab("diagnostics", "tab_diagnostics", DiagnosticsTab(), - category="system") - self._add_tab("report", "tab_report", self._build_report_tab(), - 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), - )) + # The window opens on record / script_builder / remote_desktop. Every + # other tab is registered but not built: its module is imported and + # its widget constructed the first time it is opened, from the + # navigation panel or the View menu. + own_tabs = self._own_tab_builders() + for spec in TAB_SPECS: + builder, actions = own_tabs.get(spec.key, (None, ())) + self._add_tab(spec.key, spec.title_key, + builder or lazy_factory(spec.module, spec.class_name), + category=spec.category, default_visible=spec.default_visible, actions=actions) layout.addWidget(self.tabs) self.setLayout(layout) @@ -262,11 +82,74 @@ def __init__(self, parent=None): self.repeat_max = 0 self._record_data = [] + def _own_tab_builders(self) -> Dict[str, Any]: + """Tabs this widget builds itself: ``key -> (builder, menu actions)``. + + The mixin tabs are built now, not on first open: their builders create + attributes other slots read (``script_path_input``, the record status + label), and they are plain forms with nothing running behind them. + """ + return { + "auto_click": (self._build_auto_click_tab(), ( + ("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), + )), + "screenshot": (self._build_screenshot_tab(), ( + ("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), + )), + "image_detect": (self._build_image_detect_tab(), ( + ("browse", self._browse_img), + ("crop_template", self._crop_template), + ("locate_image", self._locate_image), + ("locate_all", self._locate_all), + ("locate_click", self._locate_click), + )), + "record": (self._build_record_tab(), ( + ("start_record", self._start_record), + ("stop_record", self._stop_record), + ("playback", self._playback_record), + ("save_record", self._save_record), + ("load_record", self._load_record), + )), + "script": (self._build_script_tab(), ( + ("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), + )), + "remote_desktop": (self._build_remote_desktop_tab, ()), + "report": (self._build_report_tab(), ( + ("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), + )), + } + @staticmethod def _build_remote_desktop_tab() -> QWidget: """Return the real remote-desktop tab, or a placeholder if the - ``webrtc`` extra is not installed.""" - if RemoteDesktopTab is not None: + ``webrtc`` extra is not installed. + + Remote desktop relies on the optional ``webrtc`` extra (aiortc + PyAV); + embedders such as PyBreeze install je_auto_control without it, so a + failed import becomes a tab that says how to enable it. + """ + try: + from je_auto_control.gui.remote_desktop_tab import RemoteDesktopTab + except ImportError as error: + import_error: ImportError = error + else: return RemoteDesktopTab() placeholder = QWidget() layout = QVBoxLayout(placeholder) @@ -274,7 +157,7 @@ def _build_remote_desktop_tab() -> QWidget: "Remote Desktop is unavailable: the optional 'webrtc' extra " "(aiortc + PyAV) is not installed.\n\n" "Install with:\n pip install je_auto_control[webrtc]\n\n" - f"Underlying error: {_REMOTE_DESKTOP_IMPORT_ERROR!r}", + f"Underlying error: {import_error!r}", ) message.setWordWrap(True) message.setTextInteractionFlags(Qt.TextSelectableByMouse) @@ -285,24 +168,43 @@ def _build_remote_desktop_tab() -> QWidget: # --- tab registry API ---------------------------------------------------- def _add_tab( - self, key: str, title_key: str, widget: QWidget, + self, key: str, title_key: str, widget: Union[QWidget, WidgetFactory], category: str = "core", default_visible: bool = False, - actions: tuple = (), + actions: MenuActions = (), ) -> 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)) + """Register a tab from a widget, or from a factory called on first open.""" + if isinstance(widget, QWidget): + built = widget + entry = TabEntry(key=key, title_key=title_key, factory=lambda: built, + category=category, default_visible=default_visible, actions=actions) else: - # Owned from the start: an unparented hidden tab outlived this - # widget, and one a registry held a listener of (Presence) kept - # its timer running after every window that built it was gone. + entry = TabEntry(key=key, title_key=title_key, factory=widget, + category=category, default_visible=default_visible, actions=actions, + on_build=lambda page: AutoControlGUIWidget._adopt_hidden_tab(self, page)) + self._tab_entries.append(entry) + if default_visible: + self.tabs.addTab(entry.widget, language_wrapper.translate(title_key, title_key)) + elif isinstance(widget, QWidget): + AutoControlGUIWidget._adopt_hidden_tab(self, entry.widget) + + def _adopt_hidden_tab(self, widget: QWidget) -> None: + # Owned from the start: an unparented hidden tab outlived this + # widget, and one a registry held a listener of (Presence) kept + # its timer running after every window that built it was gone. + if self.tabs.indexOf(widget) == -1: widget.setParent(self) widget.hide() + @staticmethod + def _is_built(entry: Any) -> bool: + return bool(getattr(entry, "built", True)) + + def _built_entries(self) -> List[Any]: + return [entry for entry in self._tab_entries if self._is_built(entry)] + + def _is_open(self, entry: Any) -> bool: + return self._is_built(entry) and self.tabs.indexOf(entry.widget) != -1 + def _on_current_tab_changed(self, _index: int) -> None: self.current_tab_changed.emit() @@ -317,7 +219,7 @@ def current_tab_menu_actions(self) -> list: widget = self.tabs.currentWidget() if widget is None: return [] - for entry in self._tab_entries: + for entry in self._built_entries(): if entry.widget is widget: if entry.actions: return list(entry.actions) @@ -334,42 +236,71 @@ def _find_entry(self, key: str): return None def sync_engine_tabs(self) -> None: - """Let every tab that mirrors an engine re-read its state (after Tools > Start).""" + """Let every built tab that mirrors an engine re-read its state (after Tools > Start). + + A tab not built yet reads the engine when it is. + """ for entry in self._tab_entries: + if not AutoControlGUIWidget._is_built(entry): + continue sync = getattr(entry.widget, "sync_with_engine", None) if callable(sync): sync() def list_registered_tabs(self) -> list: - """Return metadata for the View → Tabs menu.""" + """Return metadata for the View → Tabs menu and the navigation panel. + + Reading it builds nothing: a tab never opened is listed as not visible. + """ return [ { "key": entry.key, "title": language_wrapper.translate(entry.title_key, entry.title_key), - "visible": self.tabs.indexOf(entry.widget) != -1, + "visible": self._is_open(entry), "category": entry.category, } for entry in self._tab_entries ] + def current_tab_key(self) -> Optional[str]: + """Key of the tab on screen, or ``None`` when every tab is closed.""" + widget = self.tabs.currentWidget() + for entry in self._built_entries(): + if entry.widget is widget: + return str(entry.key) + return None + def show_tab(self, key: str) -> None: + """Open the tab ``key`` (building it if this is its first time) and select it.""" entry = self._find_entry(key) - if entry is None or self.tabs.indexOf(entry.widget) != -1: + if entry is None or self._is_open(entry): return target_index = 0 for candidate in self._tab_entries: if candidate.key == key: break - if self.tabs.indexOf(candidate.widget) != -1: + if self._is_open(candidate): target_index += 1 title = language_wrapper.translate(entry.title_key, entry.title_key) self.tabs.insertTab(target_index, entry.widget, title) self.tabs.setCurrentWidget(entry.widget) self.tabs_changed.emit() - def hide_tab(self, key: str) -> None: + def activate_tab(self, key: str) -> bool: + """Bring the tab ``key`` to the front, opening it first if needed.""" entry = self._find_entry(key) if entry is None: + return False + if self._is_open(entry): + self.tabs.setCurrentWidget(entry.widget) + else: + self.show_tab(key) + return True + + def hide_tab(self, key: str) -> None: + """Close the tab ``key``; its widget is kept for the next time it is opened.""" + entry = self._find_entry(key) + if entry is None or not self._is_built(entry): return index = self.tabs.indexOf(entry.widget) if index != -1: @@ -378,7 +309,7 @@ def hide_tab(self, key: str) -> None: def _on_tab_close_requested(self, index: int) -> None: widget = self.tabs.widget(index) - for entry in self._tab_entries: + for entry in self._built_entries(): if entry.widget is widget: self.hide_tab(entry.key) return @@ -388,7 +319,7 @@ def _translate(self, key: str) -> str: def retranslate(self) -> None: """Relabel tab titles and propagate into every child tab.""" - for entry in self._tab_entries: + for entry in self._built_entries(): index = self.tabs.indexOf(entry.widget) if index != -1: self.tabs.setTabText( @@ -404,7 +335,7 @@ def retranslate(self) -> None: if hasattr(self, "_record_retranslate"): self._record_retranslate() # Child class tabs get their own retranslate if they implement one. - for entry in self._tab_entries: + for entry in self._built_entries(): callback = getattr(entry.widget, "retranslate", None) if callable(callback) and entry.widget is not self: try: @@ -415,7 +346,7 @@ def retranslate(self) -> None: def open_script_file(self, path: str) -> None: """Load a JSON script into the Script Executor tab and focus it.""" entry = self._find_entry("script") - if entry is not None and self.tabs.indexOf(entry.widget) == -1: + if entry is not None and not self._is_open(entry): self.show_tab("script") self.script_path_input.setText(path) try: diff --git a/je_auto_control/gui/main_window.py b/je_auto_control/gui/main_window.py index 8ff7f29c8..a8ef06859 100644 --- a/je_auto_control/gui/main_window.py +++ b/je_auto_control/gui/main_window.py @@ -1,17 +1,20 @@ -"""Top-level window with menu bar, closable tabs, and live language switching.""" +"""Top-level window: menu bar, feature navigation, tabbed workspace, themes, live language switching.""" import sys from PySide6.QtCore import Qt -from PySide6.QtGui import QAction, QActionGroup +from PySide6.QtGui import QAction, QActionGroup, QKeySequence from PySide6.QtWidgets import ( - QApplication, QFileDialog, QMainWindow, QMenu, QMessageBox, + QApplication, QDockWidget, QFileDialog, QMainWindow, QMenu, QMessageBox, QWidget, ) -from qt_material import QtStyleTools from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( language_wrapper, ) from je_auto_control.gui.main_widget import AutoControlGUIWidget +from je_auto_control.gui.navigation import NavigationPanel +from je_auto_control.gui.theme import ( + DEFAULT_THEME, THEMES, apply_theme, font_rule, prepare_application, theme_named, +) def _t(key: str, default: str = "") -> str: @@ -36,8 +39,14 @@ def _t(key: str, default: str = "") -> str: ) -class AutoControlGUIUI(QMainWindow, QtStyleTools): - """Main window: menu bar + AutoControlGUIWidget (which owns the tabs).""" +_THEME_LABELS = { + "dark": ("menu_view_theme_dark", "Dark"), + "light": ("menu_view_theme_light", "Light"), +} + + +class AutoControlGUIUI(QMainWindow): + """Main window: menu bar, navigation panel and AutoControlGUIWidget (which owns the tabs).""" def __init__(self) -> None: super().__init__() @@ -47,24 +56,25 @@ def __init__(self) -> None: windll.shell32.SetCurrentProcessExplicitAppUserModelID(self.app_id) self._user_font_pt: int = 0 # 0 means auto-detect from screen - self.apply_stylesheet(self, "dark_amber.xml") - # qt_material writes the theme into this window's stylesheet; capture it - # so _apply_font_pt can append the font rule instead of replacing (and - # thereby wiping) the theme. - self._theme_stylesheet: str = self.styleSheet() + self._theme_name: str = DEFAULT_THEME + # The theme's style sheet is kept so _apply_font_pt can append the + # font rule instead of replacing (and thereby wiping) the theme. + self._theme_stylesheet: str = apply_theme(self, theme_named(self._theme_name)) self._apply_font_pt(self._user_font_pt) self.setWindowTitle(_t("application_name", "AutoControlGUI")) - self.resize(1000, 760) + self.resize(1280, 800) self.auto_control_gui_widget = AutoControlGUIWidget(parent=self) self.setCentralWidget(self.auto_control_gui_widget) + self._build_navigation() 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._refresh_navigation) 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, @@ -75,6 +85,41 @@ def __init__(self) -> None: listener = self._on_language_changed self.destroyed.connect(lambda *_args: language_wrapper.remove_listener(listener)) + # --- navigation ---------------------------------------------------------- + + def _build_navigation(self) -> None: + """Dock the searchable feature list on the left of the workspace.""" + self.navigation = NavigationPanel(self) + self.navigation.feature_activated.connect(self.auto_control_gui_widget.activate_tab) + self._navigation_dock = QDockWidget(self) + self._navigation_dock.setObjectName("NavigationDock") + self._navigation_dock.setFeatures(QDockWidget.DockWidgetFeature.NoDockWidgetFeatures) + self._navigation_dock.setTitleBarWidget(QWidget(self._navigation_dock)) + self._navigation_dock.setWidget(self.navigation) + self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self._navigation_dock) + self.resizeDocks([self._navigation_dock], [260], Qt.Orientation.Horizontal) + # Owned by the window, not by a menu: the menu bar is rebuilt on every + # language switch, and a shortcut on a per-menu action would be + # registered once more each time. + self._search_action = QAction(self) + self._search_action.setShortcut(QKeySequence("Ctrl+K")) + self._search_action.triggered.connect(self._focus_feature_search) + self.addAction(self._search_action) + self._sidebar_action = self._navigation_dock.toggleViewAction() + self._sidebar_action.setShortcut(QKeySequence("Ctrl+B")) + self.addAction(self._sidebar_action) + self._refresh_navigation() + + def _refresh_navigation(self) -> None: + self.navigation.set_entries( + self.auto_control_gui_widget.list_registered_tabs(), _TAB_CATEGORIES, + ) + + def _focus_feature_search(self) -> None: + """Show the navigation panel and put the cursor in its search box (Ctrl+K).""" + self._navigation_dock.setVisible(True) + self.navigation.focus_search() + # --- menu construction --------------------------------------------------- def _build_menu_bar(self) -> None: @@ -122,14 +167,44 @@ def _build_file_menu(self) -> QMenu: def _build_view_menu(self) -> QMenu: menu = QMenu(_t("menu_view", "View"), self) + self._search_action.setText(_t("menu_view_search", "Search Features...")) + menu.addAction(self._search_action) + self._sidebar_action.setText(_t("menu_view_sidebar", "Navigation Panel")) + menu.addAction(self._sidebar_action) tabs_menu = menu.addMenu(_t("menu_view_tabs", "Tabs")) self._view_menu = tabs_menu self._rebuild_tabs_menu() menu.addSeparator() + theme_menu = menu.addMenu(_t("menu_view_theme", "Theme")) + self._build_theme_menu(theme_menu) text_menu = menu.addMenu(_t("menu_view_text_size", "Text Size")) self._build_text_size_menu(text_menu) return menu + def _build_theme_menu(self, menu: QMenu) -> None: + group = QActionGroup(menu) + group.setExclusive(True) + for name in THEMES: + label_key, default_label = _THEME_LABELS.get(name, (name, name.title())) + action = QAction(_t(label_key, default_label), menu, checkable=True) + action.setData(name) + action.setChecked(name == self._theme_name) + action.triggered.connect(self._on_theme_selected) + group.addAction(action) + menu.addAction(action) + + def _on_theme_selected(self) -> None: + action = self.sender() + if isinstance(action, QAction) and action.data(): + self.set_theme(str(action.data())) + + def set_theme(self, name: str) -> None: + """Switch to the theme called ``name`` (``dark`` or ``light``), keeping the text size.""" + tokens = theme_named(name) + self._theme_name = tokens.name + self._theme_stylesheet = apply_theme(self, tokens) + self._apply_font_pt(self._user_font_pt) + def _rebuild_tabs_menu(self) -> None: if self._view_menu is None: return @@ -177,21 +252,21 @@ def _detect_auto_font_pt(self) -> int: return 12 height = screen.geometry().height() if height >= 2000: - return 16 + return 13 if height >= 1300: - return 14 - return 12 + return 11 + return 10 def _apply_font_pt(self, pt: int) -> None: """Apply the font size on top of the active theme stylesheet. The theme lives in this window's stylesheet, so the font rule is appended rather than assigned — assigning would replace (and wipe) the - qt_material theme on startup and on every text-size change. + theme on startup and on every text-size change. The font family is + the theme's; only the size is set here. """ effective = pt if pt > 0 else self._detect_auto_font_pt() - font_rule = f"* {{ font-size: {effective}pt; font-family: 'Lato'; }}" - self.setStyleSheet(f"{self._theme_stylesheet}\n{font_rule}") + self.setStyleSheet(f"{self._theme_stylesheet}\n{font_rule(effective)}") def _on_text_size_selected(self) -> None: action = self.sender() @@ -265,6 +340,8 @@ def _on_language_selected(self) -> None: def _on_language_changed(self, _language: str) -> None: self.setWindowTitle(_t("application_name", "AutoControlGUI")) self.auto_control_gui_widget.retranslate() + self.navigation.retranslate() + self._refresh_navigation() self._build_menu_bar() def _on_about(self) -> None: @@ -296,6 +373,7 @@ def _start_triggers(self) -> None: if "__main__" == __name__: app = QApplication(sys.argv) + prepare_application(app) window = AutoControlGUIUI() window.show() sys.exit(app.exec()) diff --git a/je_auto_control/gui/navigation.py b/je_auto_control/gui/navigation.py new file mode 100644 index 000000000..4cc143aef --- /dev/null +++ b/je_auto_control/gui/navigation.py @@ -0,0 +1,200 @@ +"""Searchable feature list for the left side of the main window. + +Every registered tab appears here under its category, open or not, so a +feature is one click or a few typed letters away instead of three menus deep. +The panel only reports which key was chosen; opening the tab stays with the +window that owns the tab registry. +""" +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QFont, QKeyEvent +from PySide6.QtWidgets import ( + QLabel, QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget, +) + +from je_auto_control.gui.language_wrapper.multi_language_wrapper import ( + language_wrapper, +) + +Category = Tuple[str, str, str] +_KEY_ROLE = Qt.ItemDataRole.UserRole + + +def _t(key: str, default: str) -> str: + return language_wrapper.translate(key, default) + + +def matches(query: str, entry: Dict[str, Any], category_label: str = "") -> bool: + """Whether every word of ``query`` occurs in the entry's title, key or category.""" + haystack = " ".join((str(entry.get("title", "")), str(entry.get("key", "")).replace("_", " "), + str(entry.get("key", "")), category_label)).casefold() + return all(word in haystack for word in query.casefold().split()) + + +class _SearchField(QLineEdit): + """Line edit that hands Down / Return to the list below it.""" + + move_down = Signal() + + def keyPressEvent(self, event: QKeyEvent) -> None: # noqa: N802 # reason: Qt override + if event.key() == Qt.Key.Key_Down: + self.move_down.emit() + return + super().keyPressEvent(event) + + +class NavigationPanel(QWidget): + """Search box over a category tree of every registered tab.""" + + feature_activated = Signal(str) + + def __init__(self, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + self.setObjectName("NavigationPanel") + self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) + self._entries: List[Dict[str, Any]] = [] + self._categories: Sequence[Category] = () + + self.search = _SearchField(self) + self.search.setClearButtonEnabled(True) + self.search.textChanged.connect(self.apply_filter) + self.search.returnPressed.connect(self.activate_first_match) + self.search.move_down.connect(self._focus_first_match) + + self.tree = QTreeWidget(self) + self.tree.setHeaderHidden(True) + self.tree.setRootIsDecorated(False) + self.tree.setIndentation(12) + self.tree.setUniformRowHeights(True) + self.tree.itemClicked.connect(self._on_item_chosen) + self.tree.itemActivated.connect(self._on_item_chosen) + + self.empty = QLabel(self) + self.empty.setObjectName("NavigationEmpty") + self.empty.setWordWrap(True) + self.empty.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter) + self.empty.hide() + + layout = QVBoxLayout(self) + layout.setContentsMargins(8, 8, 8, 8) + layout.setSpacing(8) + layout.addWidget(self.search) + layout.addWidget(self.tree, 1) + layout.addWidget(self.empty, 1) + self.setMinimumWidth(200) + self.retranslate() + + # --- content ------------------------------------------------------------- + + def set_entries(self, entries: Iterable[Dict[str, Any]], categories: Sequence[Category]) -> None: + """Show ``entries`` (``key`` / ``title`` / ``category`` / ``visible``) grouped by ``categories``. + + A category is ``(key, title_key, default_title)``; entries of a + category not listed come last, under the category key itself. + """ + self._entries = [dict(entry) for entry in entries] + self._categories = tuple(categories) + self._rebuild() + + def _category_labels(self) -> List[Tuple[str, str]]: + labels = [(key, _t(title_key, default)) for key, title_key, default in self._categories] + known = {key for key, _label in labels} + for entry in self._entries: + category = str(entry.get("category", "")) + if category not in known: + known.add(category) + labels.append((category, category.title())) + return labels + + def _rebuild(self) -> None: + self.tree.clear() + heading = QFont(self.font()) + heading.setBold(True) + opened = QFont(self.font()) + opened.setBold(True) + for category, label in self._category_labels(): + members = [entry for entry in self._entries if entry.get("category") == category] + if not members: + continue + group = QTreeWidgetItem(self.tree, [label]) + group.setFont(0, heading) + group.setFlags(Qt.ItemFlag.ItemIsEnabled) + for entry in members: + item = QTreeWidgetItem(group, [str(entry.get("title", entry.get("key", "")))]) + item.setData(0, _KEY_ROLE, entry.get("key")) + if entry.get("visible"): + item.setFont(0, opened) + item.setToolTip(0, _t("nav_open_hint", "Open in the workspace")) + group.setExpanded(True) + self.apply_filter(self.search.text()) + + # --- search -------------------------------------------------------------- + + def apply_filter(self, text: str = "") -> int: + """Hide what ``text`` does not match; return how many features remain.""" + shown = 0 + for index in range(self.tree.topLevelItemCount()): + group = self.tree.topLevelItem(index) + in_group = 0 + for row in range(group.childCount()): + item = group.child(row) + entry = {"title": item.text(0), "key": item.data(0, _KEY_ROLE)} + hit = matches(text, entry, group.text(0)) + item.setHidden(not hit) + in_group += int(hit) + group.setHidden(in_group == 0) + shown += in_group + self.tree.setVisible(shown > 0) + self.empty.setVisible(shown == 0) + return shown + + def visible_keys(self) -> List[str]: + """Keys of the features the current search leaves on screen, top to bottom.""" + keys: List[str] = [] + for index in range(self.tree.topLevelItemCount()): + group = self.tree.topLevelItem(index) + for row in range(group.childCount()): + item = group.child(row) + if not item.isHidden(): + keys.append(str(item.data(0, _KEY_ROLE))) + return keys + + def _first_match(self) -> Optional[QTreeWidgetItem]: + for index in range(self.tree.topLevelItemCount()): + group = self.tree.topLevelItem(index) + for row in range(group.childCount()): + if not group.child(row).isHidden(): + return group.child(row) + return None + + def activate_first_match(self) -> None: + """Open the first feature the search shows (Return in the search box).""" + item = self._first_match() + if item is not None: + self._on_item_chosen(item) + + def _focus_first_match(self) -> None: + item = self._first_match() + if item is not None: + self.tree.setCurrentItem(item) + self.tree.setFocus(Qt.FocusReason.ShortcutFocusReason) + + def focus_search(self) -> None: + """Put the cursor in the search box with its text selected.""" + self.search.setFocus(Qt.FocusReason.ShortcutFocusReason) + self.search.selectAll() + + def _on_item_chosen(self, item: QTreeWidgetItem, _column: int = 0) -> None: + key = item.data(0, _KEY_ROLE) + if key: + self.feature_activated.emit(str(key)) + + # --- language ------------------------------------------------------------ + + def retranslate(self) -> None: + """Re-read every label from the language wrapper.""" + self.search.setPlaceholderText(_t("nav_search_placeholder", "Search features (Ctrl+K)")) + self.empty.setText(_t("nav_no_results", "No feature matches this search.")) + if self._entries: + self._rebuild() diff --git a/je_auto_control/gui/tab_registry.py b/je_auto_control/gui/tab_registry.py new file mode 100644 index 000000000..424b18266 --- /dev/null +++ b/je_auto_control/gui/tab_registry.py @@ -0,0 +1,123 @@ +"""Tab registry: what the workspace can open, and when each tab is built. + +Every feature tab is described by a :class:`TabSpec` — key, title key, +category and where its widget class lives — and nothing more is loaded until +the tab is opened. :class:`TabEntry` holds one registered tab and builds the +widget on first access to ``widget``, so the window no longer imports and +constructs some fifty panels (with the timers and helper threads several of +them start) before it can show the three it opens on. +""" +from dataclasses import dataclass, field +from importlib import import_module +from typing import Any, Callable, Optional, Tuple + +MenuActions = Tuple[Tuple[str, Callable[[], Any]], ...] +WidgetFactory = Callable[[], Any] + + +@dataclass(frozen=True) +class TabSpec: + """A tab the workspace can open; ``module`` is imported on first use.""" + + key: str + title_key: str + category: str + module: str = "" + class_name: str = "" + default_visible: bool = False + + +def lazy_factory(module: str, class_name: str) -> WidgetFactory: + """Return a factory that imports ``module`` and builds ``class_name``.""" + def build() -> Any: + return getattr(import_module(module), class_name)() + return build + + +@dataclass +class TabEntry: + """One registered tab; the widget is built the first time it is needed.""" + + key: str + title_key: str + factory: WidgetFactory + category: str = "core" + default_visible: bool = False + actions: MenuActions = () + on_build: Optional[Callable[[Any], None]] = None + _widget: Any = field(default=None, repr=False) + + @property + def built(self) -> bool: + """Whether the widget exists yet.""" + return self._widget is not None + + @property + def widget(self) -> Any: + """The tab's widget, building it on first access.""" + if self._widget is None: + self._widget = self.factory() + if self.on_build is not None: + self.on_build(self._widget) + return self._widget + + +_GUI = "je_auto_control.gui" + +# Registration order: it is the order of the View > Tabs menu, of the +# navigation panel inside each category, and of the tab bar. A spec without a +# module is one the main widget builds itself (its mixin tabs, and Remote +# Desktop, which needs an optional extra and falls back to a placeholder). +TAB_SPECS: Tuple[TabSpec, ...] = ( + TabSpec("auto_click", "tab_auto_click", "core"), + TabSpec("screenshot", "tab_screenshot", "core"), + TabSpec("image_detect", "tab_image_detect", "core"), + TabSpec("record", "tab_record", "core", default_visible=True), + TabSpec("script_builder", "tab_script_builder", "core", + f"{_GUI}.script_builder", "ScriptBuilderTab", default_visible=True), + TabSpec("flow_editor", "tab_flow_editor", "editing", f"{_GUI}.flow_editor", "FlowEditorTab"), + TabSpec("script", "tab_script", "editing"), + TabSpec("recording_editor", "tab_recording_editor", "editing", + f"{_GUI}.recording_editor_tab", "RecordingEditorTab"), + TabSpec("variables", "tab_variables", "editing", f"{_GUI}.variables_tab", "VariablesTab"), + TabSpec("secrets", "tab_secrets", "editing", f"{_GUI}.secrets_tab", "SecretsTab"), + TabSpec("vlm", "tab_vlm", "detection", f"{_GUI}.vlm_tab", "VLMTab"), + TabSpec("self_healing", "tab_self_healing", "detection", f"{_GUI}.self_healing_tab", "SelfHealingTab"), + TabSpec("ocr_reader", "tab_ocr_reader", "detection", f"{_GUI}.ocr_tab", "OCRReaderTab"), + TabSpec("accessibility", "tab_accessibility", "detection", f"{_GUI}.accessibility_tab", "AccessibilityTab"), + TabSpec("live_hud", "tab_live_hud", "detection", f"{_GUI}.live_hud_tab", "LiveHUDTab"), + TabSpec("llm_planner", "tab_llm_planner", "detection", f"{_GUI}.llm_planner_tab", "LLMPlannerTab"), + TabSpec("computer_use", "tab_computer_use", "detection", f"{_GUI}.computer_use_tab", "ComputerUseTab"), + TabSpec("scheduler", "tab_scheduler", "automation", f"{_GUI}.scheduler_tab", "SchedulerTab"), + TabSpec("hotkeys", "tab_hotkeys", "automation", f"{_GUI}.hotkeys_tab", "HotkeysTab"), + TabSpec("triggers", "tab_triggers", "automation", f"{_GUI}.triggers_tab", "TriggersTab"), + TabSpec("webhooks", "tab_webhooks", "automation", f"{_GUI}.webhooks_tab", "WebhooksTab"), + TabSpec("email_triggers", "tab_email_triggers", "automation", + f"{_GUI}.email_triggers_tab", "EmailTriggersTab"), + TabSpec("test_suite", "tab_test_suite", "core", f"{_GUI}.test_suite_tab", "TestSuiteTab"), + TabSpec("assertions", "tab_assertions", "core", f"{_GUI}.assertions_tab", "AssertionsTab"), + TabSpec("data_source", "tab_data_source", "core", f"{_GUI}.data_source_tab", "DataSourceTab"), + TabSpec("flakiness", "tab_flakiness", "system", f"{_GUI}.flakiness_tab", "FlakinessTab"), + TabSpec("a11y_audit", "tab_a11y_audit", "core", f"{_GUI}.a11y_audit_tab", "A11yAuditTab"), + TabSpec("device_matrix", "tab_device_matrix", "core", f"{_GUI}.device_matrix_tab", "DeviceMatrixTab"), + TabSpec("media_checks", "tab_media_checks", "core", f"{_GUI}.media_checks_tab", "MediaChecksTab"), + TabSpec("run_history", "tab_run_history", "automation", f"{_GUI}.run_history_tab", "RunHistoryTab"), + TabSpec("profiler", "tab_profiler", "automation", f"{_GUI}.profiler_tab", "ProfilerTab"), + TabSpec("window_manager", "tab_window_manager", "system", f"{_GUI}.window_tab", "WindowManagerTab"), + TabSpec("plugins", "tab_plugins", "system", f"{_GUI}.plugins_tab", "PluginsTab"), + TabSpec("webrunner", "tab_webrunner", "automation", f"{_GUI}.webrunner_tab", "WebRunnerTab"), + TabSpec("dag_runner", "tab_dag_runner", "automation", f"{_GUI}.dag_tab", "DagTab"), + TabSpec("chatops", "tab_chatops", "automation", f"{_GUI}.chatops_tab", "ChatOpsTab"), + TabSpec("trace_replay", "tab_trace_replay", "automation", f"{_GUI}.trace_replay_tab", "TraceReplayTab"), + TabSpec("remote_desktop", "tab_remote_desktop", "system", default_visible=True), + TabSpec("presence", "tab_presence", "system", f"{_GUI}.presence_tab", "PresenceTab"), + TabSpec("rest_api", "tab_rest_api", "system", f"{_GUI}.rest_api_tab", "RestApiTab"), + TabSpec("admin_console", "tab_admin_console", "system", f"{_GUI}.admin_console_tab", "AdminConsoleTab"), + TabSpec("audit_log", "tab_audit_log", "system", f"{_GUI}.audit_log_tab", "AuditLogTab"), + TabSpec("inspector", "tab_inspector", "system", f"{_GUI}.inspector_tab", "InspectorTab"), + TabSpec("usb_devices", "tab_usb_devices", "system", f"{_GUI}.usb_devices_tab", "UsbDevicesTab"), + TabSpec("usb_browser", "tab_usb_browser", "system", f"{_GUI}.usb_browser_tab", "UsbBrowserTab"), + TabSpec("usb_share", "tab_usb_share", "system", f"{_GUI}.usb_passthrough_panel", "UsbPassthroughPanel"), + TabSpec("diagnostics", "tab_diagnostics", "system", f"{_GUI}.diagnostics_tab", "DiagnosticsTab"), + TabSpec("report", "tab_report", "system"), +) diff --git a/je_auto_control/gui/theme.py b/je_auto_control/gui/theme.py new file mode 100644 index 000000000..9a6ad7124 --- /dev/null +++ b/je_auto_control/gui/theme.py @@ -0,0 +1,178 @@ +"""Design tokens and the stylesheet built from them. + +One :class:`ThemeTokens` value holds every colour, radius and spacing the +window uses; :func:`build_stylesheet` turns it into Qt style sheet text and +:func:`apply_theme` puts it on a window together with a matching palette, so +the parts Qt draws itself (arrows, check marks, scroll handles) agree with it. +Nothing here loads an image or a font file. +""" +from dataclasses import dataclass +from typing import Any, Dict, Optional + +FONT_FAMILY = ('"Segoe UI Variable Text", "Segoe UI", "SF Pro Text", "Helvetica Neue", ' + '"Noto Sans", "Microsoft JhengHei UI", "PingFang TC", "Noto Sans CJK TC", sans-serif') + + +@dataclass(frozen=True) +class ThemeTokens: + """Colours and metrics for one theme.""" + + name: str + window: str + surface: str + surface_raised: str + border: str + text: str + text_muted: str + accent: str + accent_hover: str + accent_text: str + selection: str + hover: str + danger: str + radius: int = 6 + spacing: int = 8 + font_family: str = FONT_FAMILY + + +DARK = ThemeTokens( + name="dark", window="#16181d", surface="#1d2026", surface_raised="#262a32", border="#343944", + text="#e7e9ee", text_muted="#9aa1ae", accent="#5b9dff", accent_hover="#7ab0ff", accent_text="#0c1220", + selection="#2c4470", hover="#2b303a", danger="#ff6b6b", +) +LIGHT = ThemeTokens( + name="light", window="#f4f5f8", surface="#ffffff", surface_raised="#eceef3", border="#d3d7df", + text="#1c2029", text_muted="#5f6775", accent="#2563eb", accent_hover="#1d4fd0", accent_text="#ffffff", + selection="#d4e2ff", hover="#e6e9f0", danger="#c62828", +) +THEMES: Dict[str, ThemeTokens] = {DARK.name: DARK, LIGHT.name: LIGHT} +DEFAULT_THEME = DARK.name + + +def theme_named(name: str) -> ThemeTokens: + """Return the theme called ``name``, or the default for an unknown name.""" + return THEMES.get(name, THEMES[DEFAULT_THEME]) + + +def font_rule(point_size: int) -> str: + """Style sheet rule setting the text size everywhere.""" + return f"* {{ font-size: {int(point_size)}pt; }}" + + +_TEMPLATE = """ +QWidget {{ background-color: {window}; color: {text}; font-family: {font_family}; }} +QMainWindow, QDialog, QDockWidget {{ background-color: {window}; }} +QLabel, QCheckBox, QRadioButton {{ background: transparent; }} +QToolTip {{ background-color: {surface_raised}; color: {text}; border: 1px solid {border}; + padding: {half}px {spacing}px; border-radius: {radius}px; }} + +QMenuBar {{ background-color: {window}; border-bottom: 1px solid {border}; padding: 2px {half}px; }} +QMenuBar::item {{ background: transparent; padding: {half}px {spacing}px; border-radius: {radius}px; }} +QMenuBar::item:selected, QMenuBar::item:pressed {{ background-color: {hover}; }} +QMenu {{ background-color: {surface}; border: 1px solid {border}; border-radius: {radius}px; padding: {half}px; }} +QMenu::item {{ padding: {half}px {wide}px {half}px {wide}px; border-radius: {small}px; }} +QMenu::item:selected {{ background-color: {selection}; }} +QMenu::item:disabled {{ color: {text_muted}; }} +QMenu::separator {{ height: 1px; background: {border}; margin: {half}px {spacing}px; }} + +QTabWidget::pane {{ border: 1px solid {border}; border-radius: {radius}px; background-color: {surface}; top: -1px; }} +QTabBar {{ background: transparent; qproperty-drawBase: 0; }} +QTabBar::tab {{ background: transparent; color: {text_muted}; padding: {spacing}px {wide}px; + border: 1px solid transparent; border-top-left-radius: {radius}px; border-top-right-radius: {radius}px; + margin-right: 2px; }} +QTabBar::tab:hover {{ color: {text}; background-color: {hover}; }} +QTabBar::tab:selected {{ color: {text}; background-color: {surface}; border-color: {border}; + border-bottom: 2px solid {accent}; }} + +QPushButton, QToolButton {{ background-color: {surface_raised}; border: 1px solid {border}; + border-radius: {radius}px; padding: {half}px {wide}px; min-height: 20px; }} +QPushButton:hover, QToolButton:hover {{ background-color: {hover}; border-color: {accent}; }} +QPushButton:pressed, QToolButton:pressed {{ background-color: {selection}; }} +QPushButton:default {{ background-color: {accent}; color: {accent_text}; border-color: {accent}; }} +QPushButton:default:hover {{ background-color: {accent_hover}; }} +QPushButton:disabled, QToolButton:disabled {{ color: {text_muted}; background-color: {window}; }} +QTabBar QToolButton {{ padding: 0; min-height: 0; border-radius: {small}px; }} + +QLineEdit, QTextEdit, QPlainTextEdit, QSpinBox, QDoubleSpinBox, QComboBox, QDateTimeEdit, QTimeEdit {{ + background-color: {surface}; border: 1px solid {border}; border-radius: {radius}px; + padding: {half}px {spacing}px; selection-background-color: {selection}; selection-color: {text}; }} +QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus, +QComboBox:focus {{ border-color: {accent}; }} +QLineEdit:disabled, QTextEdit:disabled, QPlainTextEdit:disabled, QComboBox:disabled {{ color: {text_muted}; }} +QComboBox QAbstractItemView {{ background-color: {surface}; border: 1px solid {border}; + selection-background-color: {selection}; selection-color: {text}; outline: 0; }} + +QTreeView, QListView, QTableView {{ background-color: {surface}; alternate-background-color: {window}; + border: 1px solid {border}; border-radius: {radius}px; gridline-color: {border}; outline: 0; }} +QTreeView::item, QListView::item {{ padding: {small}px {half}px; border-radius: {small}px; }} +QTreeView::item:hover, QListView::item:hover, QTableView::item:hover {{ background-color: {hover}; }} +QTreeView::item:selected, QListView::item:selected, QTableView::item:selected {{ + background-color: {selection}; color: {text}; }} +QHeaderView::section {{ background-color: {surface_raised}; color: {text_muted}; border: 0; + border-bottom: 1px solid {border}; padding: {half}px {spacing}px; }} +QTableCornerButton::section {{ background-color: {surface_raised}; border: 0; }} + +QGroupBox {{ border: 1px solid {border}; border-radius: {radius}px; margin-top: {wide}px; + padding: {spacing}px; background-color: transparent; }} +QGroupBox::title {{ subcontrol-origin: margin; left: {spacing}px; padding: 0 {half}px; color: {text_muted}; }} + +QScrollBar:vertical {{ background: transparent; width: 10px; margin: 0; }} +QScrollBar:horizontal {{ background: transparent; height: 10px; margin: 0; }} +QScrollBar::handle {{ background-color: {border}; border-radius: 4px; min-height: 24px; min-width: 24px; }} +QScrollBar::handle:hover {{ background-color: {text_muted}; }} +QScrollBar::add-line, QScrollBar::sub-line {{ width: 0; height: 0; }} +QScrollBar::add-page, QScrollBar::sub-page {{ background: transparent; }} + +QSplitter::handle {{ background-color: {border}; }} +QSplitter::handle:horizontal {{ width: 1px; }} +QSplitter::handle:vertical {{ height: 1px; }} +QProgressBar {{ background-color: {surface_raised}; border: 0; border-radius: {small}px; text-align: center; }} +QProgressBar::chunk {{ background-color: {accent}; border-radius: {small}px; }} +QStatusBar {{ background-color: {window}; border-top: 1px solid {border}; color: {text_muted}; }} + +#NavigationPanel {{ background-color: {surface}; border-right: 1px solid {border}; }} +#NavigationPanel QTreeWidget {{ background-color: {surface}; border: 0; border-radius: 0; }} +#NavigationPanel QLineEdit {{ background-color: {window}; }} +#NavigationEmpty {{ color: {text_muted}; padding: {wide}px; }} +""" + + +def build_stylesheet(tokens: ThemeTokens) -> str: + """Return the Qt style sheet for ``tokens``.""" + values = dict(vars(tokens)) + values.update(half=tokens.spacing // 2, wide=tokens.spacing * 2, small=max(tokens.radius - 2, 2)) + return _TEMPLATE.format(**values).strip() + + +def _palette(tokens: ThemeTokens) -> Any: + from PySide6.QtGui import QColor, QPalette + role = QPalette.ColorRole + palette = QPalette() + for target, colour in ( + (role.Window, tokens.window), (role.WindowText, tokens.text), (role.Base, tokens.surface), + (role.AlternateBase, tokens.window), (role.Text, tokens.text), (role.Button, tokens.surface_raised), + (role.ButtonText, tokens.text), (role.ToolTipBase, tokens.surface_raised), + (role.ToolTipText, tokens.text), (role.Highlight, tokens.accent), + (role.HighlightedText, tokens.accent_text), (role.PlaceholderText, tokens.text_muted), + (role.Link, tokens.accent), (role.BrightText, tokens.danger)): + palette.setColor(target, QColor(colour)) + for target in (role.WindowText, role.Text, role.ButtonText): + palette.setColor(QPalette.ColorGroup.Disabled, target, QColor(tokens.text_muted)) + return palette + + +def apply_theme(window: Any, tokens: ThemeTokens, point_size: Optional[int] = None) -> str: + """Style ``window`` with ``tokens``; return the style sheet without the font rule. + + ``point_size`` appends :func:`font_rule`, so the size is set on top of the + theme instead of replacing it. + """ + sheet = build_stylesheet(tokens) + window.setPalette(_palette(tokens)) + window.setStyleSheet(sheet if point_size is None else f"{sheet}\n{font_rule(point_size)}") + return sheet + + +def prepare_application(app: Any) -> None: + """Give ``app`` the Fusion style, which follows the palette on every platform.""" + app.setStyle("Fusion") diff --git a/test/unit_test/headless/test_gui_lazy_registry.py b/test/unit_test/headless/test_gui_lazy_registry.py new file mode 100644 index 000000000..f711236c0 --- /dev/null +++ b/test/unit_test/headless/test_gui_lazy_registry.py @@ -0,0 +1,154 @@ +"""The tab registry: every tab is listed, and none is built before it is opened. + +The table and ``TabEntry`` are checked without Qt. What the real widget +builds at start-up is measured in a child process: in this one, other test +modules have long since imported most tab modules. +""" +import importlib.util +import json +import os +import pathlib +import subprocess # nosec B404 # reason: runs this file's own probe with a fixed argv +import sys + +import pytest + +from je_auto_control.gui.tab_registry import TAB_SPECS, TabEntry, lazy_factory + +ROOT = pathlib.Path(__file__).resolve().parents[3] +# Built by the main widget itself: its mixin tabs, and Remote Desktop's placeholder fallback. +OWN_TABS = {"auto_click", "screenshot", "image_detect", "record", "script", "remote_desktop", "report"} +OPEN_AT_START = ["record", "script_builder", "remote_desktop"] + + +def test_keys_are_unique_and_the_start_tabs_are_the_three_documented(): + keys = [spec.key for spec in TAB_SPECS] + assert len(keys) == len(set(keys)) + assert [spec.key for spec in TAB_SPECS if spec.default_visible] == OPEN_AT_START + assert {spec.key for spec in TAB_SPECS if not spec.module} == OWN_TABS + + +def test_importing_the_registry_loads_no_qt(): + probe = ("import sys; import je_auto_control.gui.tab_registry; " + "print(any(name.startswith('PySide6') for name in sys.modules))") + done = subprocess.run([sys.executable, "-c", probe], capture_output=True, text=True, # nosec B603 # nosemgrep # reason: fixed argv, no shell + timeout=120, cwd=str(ROOT), check=False) + assert done.returncode == 0, done.stderr[-2000:] + assert done.stdout.strip() == "False" + + +@pytest.mark.parametrize("spec", [spec for spec in TAB_SPECS if spec.module], ids=lambda spec: spec.key) +def test_every_lazy_tab_names_a_class_that_exists(spec): + """Read, not imported: a typo here would otherwise surface when a user opens the tab.""" + found = importlib.util.find_spec(spec.module) + assert found is not None and found.origin, spec.module + source = pathlib.Path(found.origin).read_text(encoding="utf-8") + assert (f"class {spec.class_name}(" in source or f"import {spec.class_name}" in source + or f"{spec.class_name}," in source or f'"{spec.class_name}"' in source), (spec.module, spec.class_name) + + +def test_an_entry_builds_its_widget_once_and_only_when_asked(): + built, adopted = [], [] + + def factory(): + built.append(object()) + return built[-1] + + entry = TabEntry("k", "tab_k", factory, on_build=adopted.append) + assert not entry.built and built == [] + first = entry.widget + assert entry.built and entry.widget is first + assert built == [first] and adopted == [first] + + +def test_a_lazy_factory_imports_at_call_time(): + factory = lazy_factory("collections", "OrderedDict") + assert type(factory()).__name__ == "OrderedDict" + with pytest.raises(ModuleNotFoundError): + lazy_factory("je_auto_control.gui.no_such_tab", "Missing")() + + +_PROBE = r""" +import json, os, sys +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +from PySide6.QtWidgets import QApplication +from je_auto_control.gui.main_widget import AutoControlGUIWidget +from je_auto_control.gui.tab_registry import TAB_SPECS + +app = QApplication.instance() or QApplication([]) +widget = AutoControlGUIWidget() +entries = {entry.key: entry for entry in widget._tab_entries} +report = { + "registered": [entry.key for entry in widget._tab_entries], + "specs": [spec.key for spec in TAB_SPECS], + "built_at_start": [key for key, entry in entries.items() if entry.built], + "open_at_start": [row["key"] for row in widget.list_registered_tabs() if row["visible"]], + "variables_imported_at_start": "je_auto_control.gui.variables_tab" in sys.modules, + "presence_imported_at_start": "je_auto_control.gui.presence_tab" in sys.modules, +} +widget.list_registered_tabs() +widget.retranslate() +widget.sync_engine_tabs() +widget.hide_tab("variables") +report["built_after_listing"] = [key for key, entry in entries.items() if entry.built] + +changes = [] +widget.tabs_changed.connect(lambda: changes.append(1)) +widget.show_tab("variables") +first = entries["variables"].widget +report["variables_imported_after_open"] = "je_auto_control.gui.variables_tab" in sys.modules +report["current_after_open"] = widget.current_tab_key() +widget.hide_tab("variables") +report["hidden_is_owned"] = widget.isAncestorOf(first) and widget.tabs.indexOf(first) == -1 +report["activated"] = widget.activate_tab("variables") +report["same_widget_on_reopen"] = entries["variables"].widget is first +report["tab_order"] = [row["key"] for row in widget.list_registered_tabs() if row["visible"]] +widget.activate_tab("record") +report["current_after_activate"] = widget.current_tab_key() +report["changes"] = len(changes) +report["unknown"] = widget.activate_tab("no_such_tab") +sys.stdout.write(json.dumps(report)) +sys.stdout.flush() +os._exit(0) +""" + + +@pytest.fixture(scope="module") +def report(): + pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) + env = dict(os.environ, PYTHONPATH=str(ROOT)) + env.setdefault("QT_QPA_PLATFORM", "offscreen") + done = subprocess.run([sys.executable, "-c", _PROBE], capture_output=True, text=True, # nosec B603 # nosemgrep # reason: fixed argv, no shell + timeout=180, env=env, cwd=str(ROOT), check=False) + assert done.returncode == 0, done.stderr[-2000:] + return json.loads(done.stdout) + + +def test_every_spec_is_registered_in_order(report): + assert report["registered"] == report["specs"] + + +def test_only_the_start_tabs_and_the_widgets_own_forms_are_built(report): + assert set(report["built_at_start"]) == OWN_TABS | set(OPEN_AT_START) + assert report["open_at_start"] == OPEN_AT_START + assert not report["variables_imported_at_start"] + assert not report["presence_imported_at_start"] + + +def test_listing_translating_and_hiding_build_nothing(report): + assert report["built_after_listing"] == report["built_at_start"] + + +def test_opening_builds_the_tab_and_reopening_reuses_it(report): + assert report["variables_imported_after_open"] + assert report["current_after_open"] == "variables" + assert report["hidden_is_owned"] + assert report["activated"] and report["same_widget_on_reopen"] + # Registration order, not opening order: Variables sits between Script Builder and Remote Desktop. + assert report["tab_order"] == ["record", "script_builder", "variables", "remote_desktop"] + + +def test_activating_an_open_tab_only_selects_it(report): + assert report["current_after_activate"] == "record" + assert report["changes"] == 3 # opened, hidden, opened again + assert report["unknown"] is False diff --git a/test/unit_test/headless/test_gui_slots_menu_audit.py b/test/unit_test/headless/test_gui_slots_menu_audit.py index e006301e8..cff7b4567 100644 --- a/test/unit_test/headless/test_gui_slots_menu_audit.py +++ b/test/unit_test/headless/test_gui_slots_menu_audit.py @@ -184,8 +184,6 @@ def sample_pixel(_x, _y): def test_rebuilding_the_tabs_menu_does_not_leak(): - # main_window imports qt_material (its theme), which the headless CI job does not install. - pytest.importorskip("qt_material", exc_type=ImportError) app = _app() from PySide6.QtCore import QCoreApplication, QEvent from PySide6.QtWidgets import QMenu, QWidget diff --git a/test/unit_test/headless/test_gui_tab_lifecycle_audit.py b/test/unit_test/headless/test_gui_tab_lifecycle_audit.py index 584c3f985..1bdd195c9 100644 --- a/test/unit_test/headless/test_gui_tab_lifecycle_audit.py +++ b/test/unit_test/headless/test_gui_tab_lifecycle_audit.py @@ -281,6 +281,8 @@ def test_each_usb_prompt_dialog_is_deleted(qapp, monkeypatch): before = (len(language_wrapper._listeners), len(default_presence_registry()._listeners)) from je_auto_control.gui.main_window import AutoControlGUIUI window = AutoControlGUIUI() + # Tabs are built on first open; Presence is the one holding a registry listener. + window.auto_control_gui_widget.show_tab("presence") during = (len(language_wrapper._listeners), len(default_presence_registry()._listeners)) window.deleteLater() del window @@ -292,9 +294,6 @@ def test_each_usb_prompt_dialog_is_deleted(qapp, monkeypatch): def test_destroying_the_window_removes_its_listeners(): - # The main window styles itself with qt_material, which the headless CI - # jobs do not install; the probe imports the real window. - pytest.importorskip("qt_material", exc_type=ImportError) env = dict(os.environ, PYTHONPATH=str(_REPO_ROOT), QT_QPA_PLATFORM="offscreen") argv = [sys.executable, "-c", _WINDOW_PROBE] done = subprocess.run(argv, capture_output=True, text=True, timeout=180, env=env, cwd=str(_REPO_ROOT), check=False) # nosec B603 # nosemgrep # reason: this test's own probe, fixed argv diff --git a/test/unit_test/headless/test_gui_workspace_navigation.py b/test/unit_test/headless/test_gui_workspace_navigation.py new file mode 100644 index 000000000..a54e806bf --- /dev/null +++ b/test/unit_test/headless/test_gui_workspace_navigation.py @@ -0,0 +1,226 @@ +"""Theme tokens, the navigation panel's search, and the window that hosts both.""" +import json +import os +import pathlib +import re +import subprocess # nosec B404 # reason: runs this file's own probe with a fixed argv +import sys + +import pytest + +from je_auto_control.gui.tab_registry import TAB_SPECS +from je_auto_control.gui.theme import ( + DARK, DEFAULT_THEME, LIGHT, THEMES, build_stylesheet, font_rule, theme_named, +) + +CATEGORIES = (("core", "menu_view_cat_core", "Core"), ("system", "menu_view_cat_system", "System")) +ENTRIES = [ + {"key": "record", "title": "Record / Playback", "category": "core", "visible": True}, + {"key": "usb_devices", "title": "USB Devices", "category": "system", "visible": False}, + {"key": "usb_browser", "title": "USB Browser", "category": "system", "visible": False}, + {"key": "live_hud", "title": "Live HUD", "category": "detection", "visible": False}, +] + + +# --- theme: no Qt needed ----------------------------------------------------- + +@pytest.mark.parametrize("tokens", [DARK, LIGHT], ids=lambda tokens: tokens.name) +def test_the_stylesheet_is_complete(tokens): + sheet = build_stylesheet(tokens) + assert not re.search(r"\{[a-z_]+\}", sheet), "an unfilled placeholder is left" + assert sheet.count("{") == sheet.count("}") + for colour in (tokens.window, tokens.surface, tokens.text, tokens.accent, tokens.border, tokens.selection): + assert colour in sheet + assert "#NavigationPanel" in sheet and "QTabBar::tab:selected" in sheet + + +def test_the_two_themes_differ_and_an_unknown_name_falls_back(): + assert set(THEMES) == {"dark", "light"} + assert build_stylesheet(DARK) != build_stylesheet(LIGHT) + assert theme_named("light") is LIGHT + assert theme_named("no-such-theme") is THEMES[DEFAULT_THEME] + + +def test_the_font_rule_sets_only_the_size(): + assert font_rule(14) == "* { font-size: 14pt; }" + + +def test_text_contrast_is_readable_in_both_themes(): + def luminance(colour): + channels = [int(colour[i:i + 2], 16) / 255 for i in (1, 3, 5)] + linear = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in channels] + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] + + def contrast(first, second): + high, low = sorted((luminance(first), luminance(second)), reverse=True) + return (high + 0.05) / (low + 0.05) + + for tokens in (DARK, LIGHT): + assert contrast(tokens.text, tokens.window) >= 7, tokens.name + assert contrast(tokens.text, tokens.surface) >= 7, tokens.name + assert contrast(tokens.text_muted, tokens.surface) >= 4.5, tokens.name + assert contrast(tokens.accent_text, tokens.accent) >= 4.5, tokens.name + + +# --- navigation panel -------------------------------------------------------- + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + + +@pytest.fixture(scope="module") +def qapp(): + widgets = pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) + return widgets.QApplication.instance() or widgets.QApplication([]) + + +@pytest.fixture +def panel(qapp): + from je_auto_control.gui.navigation import NavigationPanel + navigation = NavigationPanel() + navigation.set_entries(ENTRIES, CATEGORIES) + yield navigation + navigation.deleteLater() + + +def test_every_entry_is_listed_under_its_category(panel): + assert panel.visible_keys() == ["record", "usb_devices", "usb_browser", "live_hud"] + groups = [panel.tree.topLevelItem(i).text(0) for i in range(panel.tree.topLevelItemCount())] + assert groups == ["Core", "System", "Detection"] # an unlisted category comes last + + +def test_search_matches_title_key_and_category(panel): + assert panel.apply_filter("usb") == 2 + assert panel.visible_keys() == ["usb_devices", "usb_browser"] + assert panel.apply_filter("USB brow") == 1 # every word, any case + assert panel.apply_filter("live_hud") == 1 # the key as written in scripts + assert panel.apply_filter("system") == 2 # the category name + assert panel.apply_filter("") == 4 + + +def test_no_match_shows_the_empty_state_instead_of_a_blank_list(panel): + assert panel.apply_filter("zzz") == 0 + assert panel.tree.isHidden() and not panel.empty.isHidden() + panel.apply_filter("") + assert not panel.tree.isHidden() and panel.empty.isHidden() + + +def test_return_in_the_search_box_opens_the_first_match(panel): + chosen = [] + panel.feature_activated.connect(chosen.append) + panel.search.setText("usb") + panel.search.returnPressed.emit() + assert chosen == ["usb_devices"] + panel.search.setText("zzz") + panel.search.returnPressed.emit() + assert chosen == ["usb_devices"] # nothing to open + + +def test_clicking_a_category_heading_opens_nothing(panel): + chosen = [] + panel.feature_activated.connect(chosen.append) + panel.tree.itemClicked.emit(panel.tree.topLevelItem(0), 0) + panel.tree.itemClicked.emit(panel.tree.topLevelItem(0).child(0), 0) + assert chosen == ["record"] + + +def test_a_search_survives_the_list_being_refreshed(panel): + panel.search.setText("usb") + panel.set_entries(ENTRIES, CATEGORIES) # what a tab opening triggers + assert panel.visible_keys() == ["usb_devices", "usb_browser"] + + +# --- the window -------------------------------------------------------------- +# In a child process: the window builds Remote Desktop and the Script Builder, +# whose native helper threads must not be torn down inside this interpreter. + +_PROBE = r""" +import json, os, sys +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +from PySide6.QtWidgets import QApplication +from je_auto_control.gui.main_window import AutoControlGUIUI +from je_auto_control.gui.tab_registry import TAB_SPECS +from je_auto_control.gui.theme import DARK, LIGHT, font_rule + +app = QApplication.instance() or QApplication([]) +window = AutoControlGUIUI() +window.show() +app.processEvents() +workspace, navigation = window.auto_control_gui_widget, window.navigation +report = {"reachable": sorted(navigation.visible_keys()), "registered": sorted(s.key for s in TAB_SPECS)} + +navigation.search.setText("variables") +navigation.activate_first_match() +report["opened"] = workspace.current_tab_key() +report["listed_open"] = "variables" in {r["key"] for r in workspace.list_registered_tabs() if r["visible"]} +navigation.search.setText("") +workspace.hide_tab("variables") + +window._navigation_dock.setVisible(False) +window._search_action.trigger() +report["panel_shown_by_shortcut"] = not window._navigation_dock.isHidden() +report["shortcuts"] = [window._search_action.shortcut().toString(), window._sidebar_action.shortcut().toString()] + +window._user_font_pt = 14 +window.set_theme("light") +report["light"] = LIGHT.window in window.styleSheet() and font_rule(14) in window.styleSheet() +window.set_theme("dark") +report["dark"] = DARK.window in window.styleSheet() and font_rule(14) in window.styleSheet() +window._user_font_pt = 0 +window._apply_font_pt(0) + +before = len(window.actions()) +window._on_language_changed("English") +window._on_language_changed("English") +report["actions_before"], report["actions_after"] = before, len(window.actions()) +report["placeholder"] = navigation.search.placeholderText() +view = next(a.menu() for a in window.menuBar().actions() if a.menu() is not None + and window._search_action in a.menu().actions()) +report["view_menu"] = [a.text() for a in view.actions() if a.text()] + +window.resize(640, 420) +app.processEvents() +report["small"] = [window.width(), window.height(), workspace.width(), navigation.isVisible()] +sys.stdout.write(json.dumps(report)) +sys.stdout.flush() +os._exit(0) +""" + + +@pytest.fixture(scope="module") +def window(): + pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) + root = pathlib.Path(__file__).resolve().parents[3] + env = dict(os.environ, PYTHONPATH=str(root), QT_QPA_PLATFORM="offscreen") + done = subprocess.run([sys.executable, "-c", _PROBE], capture_output=True, text=True, # nosec B603 # nosemgrep # reason: fixed argv, no shell + timeout=180, env=env, cwd=str(root), check=False) + assert done.returncode == 0, done.stderr[-2000:] + return json.loads(done.stdout) + + +def test_search_reaches_every_registered_feature(window): + assert window["reachable"] == window["registered"] == sorted(spec.key for spec in TAB_SPECS) + + +def test_choosing_a_feature_opens_it_and_marks_it_open(window): + assert window["opened"] == "variables" and window["listed_open"] + + +def test_the_search_shortcut_reveals_a_hidden_panel(window): + assert window["panel_shown_by_shortcut"] + assert window["shortcuts"] == ["Ctrl+K", "Ctrl+B"] + + +def test_switching_theme_keeps_the_text_size(window): + assert window["light"] and window["dark"] + + +def test_rebuilding_the_menus_for_a_language_switch_adds_no_shortcut(window): + assert window["actions_after"] == window["actions_before"] + assert window["placeholder"] + assert window["view_menu"] == ["Search Features...", "Navigation Panel", "Tabs", "Theme", "Text Size"] + + +def test_the_window_shrinks_to_a_small_screen_with_both_panes_usable(window): + width, height, workspace_width, panel_visible = window["small"] + assert (width, height) == (640, 420), "the window refused to shrink" + assert panel_visible and workspace_width >= 300 diff --git a/test/unit_test/headless/test_qt_material_guard.py b/test/unit_test/headless/test_qt_material_guard.py deleted file mode 100644 index cbef871cf..000000000 --- a/test/unit_test/headless/test_qt_material_guard.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Every headless test that imports the main window skips without qt_material. - -``gui/main_window.py`` imports ``qt_material`` for its theme. Developers have -it installed, the ``pytest-headless`` CI job does not, so a test that imports -the window without ``pytest.importorskip("qt_material")`` passes locally and -fails every CI square. This reads the test sources, so it fails on the -developer's machine too. -""" -import ast -from pathlib import Path - -_HEADLESS = Path(__file__).resolve().parent -_WINDOW = "je_auto_control.gui.main_window" - - -def _imports_window(node: ast.AST) -> bool: - if isinstance(node, ast.ImportFrom): - return node.module == _WINDOW or (node.module == "je_auto_control.gui" - and any(alias.name == "main_window" for alias in node.names)) - if isinstance(node, ast.Import): - return any(alias.name == _WINDOW for alias in node.names) - return isinstance(node, ast.Constant) and isinstance(node.value, str) and _WINDOW in node.value - - -def _skips_without_theme(node: ast.AST) -> bool: - return (isinstance(node, ast.Call) and getattr(node.func, "attr", None) == "importorskip" - and bool(node.args) and isinstance(node.args[0], ast.Constant) - and node.args[0].value == "qt_material") - - -def _unguarded(tree: ast.Module) -> list: - """Names of the functions (or ````) that import the window unguarded.""" - if any(_skips_without_theme(node) for statement in tree.body for node in ast.walk(statement) - if not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef))): - return [] - scopes = [node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))] - missing = [scope.name for scope in scopes - if any(_imports_window(node) for node in ast.walk(scope)) - and not any(_skips_without_theme(node) for node in ast.walk(scope))] - module_level = [node for statement in tree.body - if not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) - for node in ast.walk(statement)] - if any(isinstance(node, (ast.Import, ast.ImportFrom)) and _imports_window(node) for node in module_level): - missing.append("") - return missing - - -def test_every_main_window_import_skips_without_qt_material(): - offenders = {} - for path in sorted(_HEADLESS.glob("test_*.py")): - if path.name == Path(__file__).name: - continue # its own fixtures are strings, not imports - missing = _unguarded(ast.parse(path.read_text(encoding="utf-8"))) - if missing: - offenders[path.name] = missing - assert not offenders, offenders - - -def test_the_check_sees_an_unguarded_import(): - source = "def test_x():\n from je_auto_control.gui.main_window import AutoControlGUIUI\n" - assert _unguarded(ast.parse(source)) == ["test_x"] - guarded = ("import pytest\ndef test_x():\n pytest.importorskip('qt_material', exc_type=ImportError)\n" - " from je_auto_control.gui.main_window import AutoControlGUIUI\n") - assert _unguarded(ast.parse(guarded)) == [] diff --git a/test/unit_test/headless/test_r3_gui_main_window.py b/test/unit_test/headless/test_r3_gui_main_window.py index 69202b3cb..22a37bf5f 100644 --- a/test/unit_test/headless/test_r3_gui_main_window.py +++ b/test/unit_test/headless/test_r3_gui_main_window.py @@ -1,9 +1,9 @@ """Round-3 GUI audit regression: applying the font must not wipe the theme. ``_apply_font_pt`` used to call ``setStyleSheet("font-size: ...")`` which -*replaces* the widget stylesheet, discarding the qt_material theme that -``apply_stylesheet`` had just installed (finding 5). The font rule must now be -merged on top of the captured theme stylesheet instead. +*replaces* the widget stylesheet, discarding the theme that had just been +installed (finding 5). The font rule must now be merged on top of the captured +theme stylesheet instead. """ import os @@ -11,10 +11,6 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") pytest.importorskip("PySide6.QtWidgets", exc_type=ImportError) -# main_window imports qt_material (the theme); the headless CI job installs -# PySide6 but not the GUI theme extra, so skip cleanly there rather than erroring -# out collection for the whole suite. -pytest.importorskip("qt_material") from PySide6.QtWidgets import QApplication, QMainWindow # noqa: E402 From 82fb01fcc041f1c6fa234288ca46426a64f5785a Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 9 Oct 2026 00:03:43 +0800 Subject: [PATCH 2/3] Mark the tab registry's import as table-driven for the static analyser --- architecture_explore.md | 8 ++++---- je_auto_control/gui/tab_registry.py | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index e466e94c7..53d2b9873 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,068 | -| 程式碼總行數 | 158,285 | +| 程式碼總行數 | 158,286 | | `je_auto_control/utils/` 子套件數 | 310 | | `AC_*` 動作指令數(`known_commands()` 實測) | 778 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -883,7 +883,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,PySide6 + qt-mate | `gui/__init__.py` | 25 | `start_autocontrol_gui()`:**唯一**會延遲匯入 PySide6 的地方,維持頂層套件 Qt-free。 | | `main_window.py` | 379 | `QMainWindow`:選單列(File/Actions/View/…)、左側導覽面板 dock(`Ctrl+K` 搜尋、`Ctrl+B` 收合)、即時語言切換、字級預設、深色/淺色主題(`theme.py` 的 token,不再用 qt-material)。分頁分為 core/editing/detection/automation/system 五類。 | | `main_widget.py` | 368 | 工作區:擁有 `QTabWidget`,依 `tab_registry.TAB_SPECS` 註冊 48 個分頁,並暴露 show/hide/activate/list API 給選單列與導覽面板。只有預設開啟的三個分頁與自己的 mixin 表單在啟動時建立,其餘第一次開啟才匯入模組、建立 widget。核心分頁在 `_own_tab_builders` 宣告 `(label_key, handler)` 動作對。 | -| `tab_registry.py` | 123 | 分頁表:每個分頁一筆 `TabSpec`(鍵、標題鍵、分類、模組與類別名),`TabEntry` 在第一次存取 `widget` 時才呼叫 factory。不匯入 Qt。 | +| `tab_registry.py` | 124 | 分頁表:每個分頁一筆 `TabSpec`(鍵、標題鍵、分類、模組與類別名),`TabEntry` 在第一次存取 `widget` 時才呼叫 factory。不匯入 Qt。 | | `navigation.py` | 200 | `NavigationPanel`:搜尋框 + 依分類的功能樹,列出每個已註冊分頁(開啟中的以粗體標示),只回報被選的鍵,開啟分頁仍由視窗負責。 | | `theme.py` | 178 | 設計 token(`ThemeTokens`:顏色、圓角、間距、字族)、深色與淺色兩組值、由 token 產生的樣式表與對應的 `QPalette`;不載入圖檔或字型檔。 | | `_auto_click_tab.py` | 291 | 自動點擊分頁的 mixin 建構器。 | @@ -1079,7 +1079,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | -| `gui/` | 98 | 28,397 | +| `gui/` | 98 | 28,398 | | `utils/mcp_server/` | 35 | 18,898 | | `utils/remote_desktop/` | 56 | 13,014 | | `utils/executor/` | 8 | 9,606 | @@ -1100,5 +1100,5 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 852 | | 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 682 | 57,156 | -| **總計** | **1,062** | **158,220** | +| **總計** | **1,062** | **158,221** | diff --git a/je_auto_control/gui/tab_registry.py b/je_auto_control/gui/tab_registry.py index 424b18266..4562045d4 100644 --- a/je_auto_control/gui/tab_registry.py +++ b/je_auto_control/gui/tab_registry.py @@ -30,7 +30,8 @@ class TabSpec: def lazy_factory(module: str, class_name: str) -> WidgetFactory: """Return a factory that imports ``module`` and builds ``class_name``.""" def build() -> Any: - return getattr(import_module(module), class_name)() + # reason: both names come from the TAB_SPECS table below, never from user input. + return getattr(import_module(module), class_name)() # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import return build From 70cb44863414373d7d50635adb60017cbb66d9a7 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Fri, 9 Oct 2026 00:04:11 +0800 Subject: [PATCH 3/3] Keep the analyser marker within the line limit --- architecture_explore.md | 8 ++++---- je_auto_control/gui/tab_registry.py | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/architecture_explore.md b/architecture_explore.md index 53d2b9873..cff317661 100644 --- a/architecture_explore.md +++ b/architecture_explore.md @@ -20,7 +20,7 @@ iOS(WebDriverAgent)。核心能力是滑鼠/鍵盤控制、影像辨識、 | 指標 | 數值 | | --- | ---: | | Python 模組總數(含周邊子專案) | 1,068 | -| 程式碼總行數 | 158,286 | +| 程式碼總行數 | 158,287 | | `je_auto_control/utils/` 子套件數 | 310 | | `AC_*` 動作指令數(`known_commands()` 實測) | 778 | | 套件門面 `__all__` 公開名稱數 | 1,244 | @@ -883,7 +883,7 @@ GUI 是**選用 extra**(`pip install je_auto_control[gui]`,PySide6 + qt-mate | `gui/__init__.py` | 25 | `start_autocontrol_gui()`:**唯一**會延遲匯入 PySide6 的地方,維持頂層套件 Qt-free。 | | `main_window.py` | 379 | `QMainWindow`:選單列(File/Actions/View/…)、左側導覽面板 dock(`Ctrl+K` 搜尋、`Ctrl+B` 收合)、即時語言切換、字級預設、深色/淺色主題(`theme.py` 的 token,不再用 qt-material)。分頁分為 core/editing/detection/automation/system 五類。 | | `main_widget.py` | 368 | 工作區:擁有 `QTabWidget`,依 `tab_registry.TAB_SPECS` 註冊 48 個分頁,並暴露 show/hide/activate/list API 給選單列與導覽面板。只有預設開啟的三個分頁與自己的 mixin 表單在啟動時建立,其餘第一次開啟才匯入模組、建立 widget。核心分頁在 `_own_tab_builders` 宣告 `(label_key, handler)` 動作對。 | -| `tab_registry.py` | 124 | 分頁表:每個分頁一筆 `TabSpec`(鍵、標題鍵、分類、模組與類別名),`TabEntry` 在第一次存取 `widget` 時才呼叫 factory。不匯入 Qt。 | +| `tab_registry.py` | 125 | 分頁表:每個分頁一筆 `TabSpec`(鍵、標題鍵、分類、模組與類別名),`TabEntry` 在第一次存取 `widget` 時才呼叫 factory。不匯入 Qt。 | | `navigation.py` | 200 | `NavigationPanel`:搜尋框 + 依分類的功能樹,列出每個已註冊分頁(開啟中的以粗體標示),只回報被選的鍵,開啟分頁仍由視窗負責。 | | `theme.py` | 178 | 設計 token(`ThemeTokens`:顏色、圓角、間距、字族)、深色與淺色兩組值、由 token 產生的樣式表與對應的 `QPalette`;不載入圖檔或字型檔。 | | `_auto_click_tab.py` | 291 | 自動點擊分頁的 mixin 建構器。 | @@ -1079,7 +1079,7 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | 層/子系統 | 檔案數 | 行數 | | --- | ---: | ---: | -| `gui/` | 98 | 28,398 | +| `gui/` | 98 | 28,399 | | `utils/mcp_server/` | 35 | 18,898 | | `utils/remote_desktop/` | 56 | 13,014 | | `utils/executor/` | 8 | 9,606 | @@ -1100,5 +1100,5 @@ socket 預設綁 `127.0.0.1`;資源一律用 `with`。 | `autocontrol-lsp/` | 8 | 744 | | `utils/hotkey/` | 7 | 852 | | 其餘模組(約 286 個 `utils/` 子套件 + `android/`/`ios/`/周邊小工具) | 682 | 57,156 | -| **總計** | **1,062** | **158,221** | +| **總計** | **1,062** | **158,222** | diff --git a/je_auto_control/gui/tab_registry.py b/je_auto_control/gui/tab_registry.py index 4562045d4..dd9633464 100644 --- a/je_auto_control/gui/tab_registry.py +++ b/je_auto_control/gui/tab_registry.py @@ -31,7 +31,8 @@ def lazy_factory(module: str, class_name: str) -> WidgetFactory: """Return a factory that imports ``module`` and builds ``class_name``.""" def build() -> Any: # reason: both names come from the TAB_SPECS table below, never from user input. - return getattr(import_module(module), class_name)() # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import + loaded = import_module(module) # nosemgrep: python.lang.security.audit.non-literal-import.non-literal-import + return getattr(loaded, class_name)() return build