From d477a4d8b73b679a9bf8847c133fe099d99fefef Mon Sep 17 00:00:00 2001 From: ykcheungab Date: Mon, 19 Oct 2026 10:14:58 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=20Studio=20=E6=8E=A7?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E8=8F=9C=E5=8D=95=E6=9E=84=E5=BB=BA=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将界面构建从主窗口移出,保留现有业务回调与标定路径。验证结果和完整门禁限制见 docs/architecture/studio-window-refactor-plan.zh.md。 --- apps/studio/src/orisys_studio/app_window.py | 312 ++++-------------- .../src/orisys_studio/ui/window_menus.py | 2 +- apps/studio/src/orisys_studio/ui/window_ui.py | 2 +- apps/studio/tests/test_window_surfaces.py | 72 +++- docs/architecture/studio-architecture.md | 21 +- .../studio-window-refactor-plan.zh.md | 40 ++- 6 files changed, 189 insertions(+), 260 deletions(-) diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index e1b9b21..dba4b5b 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -28,20 +28,16 @@ from orisys_app_shared.force.filters import FilterSettings from orisys_app_shared.force.session import SessionStore from orisys_app_shared.i18n import set_locale, t from orisys_app_shared.logging import report_error -from orisys_app_shared.paths import logo_path, recordings_dir, sensor_json_path +from orisys_app_shared.paths import recordings_dir, sensor_json_path from orisys_app_shared.prefs import save_preferences from orisys_app_shared.record import RecordConfig from orisys_app_shared.theme import build_qss, clear_theme_cache -from PySide6.QtCore import Qt, QTimer -from PySide6.QtGui import QAction, QActionGroup, QIcon +from PySide6.QtCore import QTimer from PySide6.QtWidgets import ( QApplication, QMainWindow, QMessageBox, - QSplitter, QStatusBar, - QVBoxLayout, - QWidget, ) from orisys_studio import session_metadata @@ -56,12 +52,7 @@ from orisys_studio.scene3d.prefs import settings_to_prefs from orisys_studio.session_controller import SessionController from orisys_studio.status_presenter import RailState, StatusPresenter from orisys_studio.ui.about_dialog import AboutDialog -from orisys_studio.ui.analysis_progress import AnalysisProgress -from orisys_studio.ui.arrow_panel import ArrowPanel, bgr_to_qimage -from orisys_studio.ui.brand_bar import BrandBar -from orisys_studio.ui.cal_panel import CalPanel -from orisys_studio.ui.force_plots import ForcePlotsPanel -from orisys_studio.ui.mesh3d_panel import Mesh3dPanel +from orisys_studio.ui.arrow_panel import bgr_to_qimage from orisys_studio.ui.finger_orientation_dialog import FingerOrientationDialog from orisys_studio.ui.plot_settings_dialog import ( DEFAULT_PLOT_FN_MAX, @@ -71,9 +62,9 @@ from orisys_studio.ui.plot_settings_dialog import ( DEFAULT_PLOT_WINDOW_SEC, PlotSettingsDialog, ) -from orisys_studio.ui.sensor_picker import SensorPickerWidget -from orisys_studio.ui.toolbar import ToolbarWidget from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog +from orisys_studio.ui.window_menus import WindowMenus +from orisys_studio.ui.window_ui import WindowUi from orisys_studio.view_layout import ViewLayoutController logger = logging.getLogger(__name__) @@ -228,54 +219,25 @@ class StudioMainWindow(QMainWindow): #: Built once the panels exist; 2D paint and shared depth span live there. self.frame_view: FrameView | None = None - icon = QIcon(str(logo_path())) - if not icon.isNull(): - self.setWindowIcon(icon) - - central = QWidget() - self.setCentralWidget(central) - root = QVBoxLayout(central) - root.setContentsMargins(10, 10, 10, 10) - root.setSpacing(8) - - self.brand_bar = BrandBar(theme=self._theme) - root.addWidget(self.brand_bar) - - # The sensor picker itself lives in the sensor dialog; the window owns it - # (it is the state every other panel depends on) and the rail shows a - # summary button that opens it. - self.sensor_picker = SensorPickerWidget() + self.ui = WindowUi(self, theme=self._theme) + # Preserve the widget entry points used by controllers, tests and UI tools. + self.brand_bar = self.ui.brand_bar + self.sensor_picker = self.ui.sensor_picker + self.toolbar = self.ui.toolbar + self.status_chip = self.ui.status_chip + self.analysis_progress = self.ui.analysis_progress + self.content_splitter = self.ui.content_splitter + self.mesh3d_panel = self.ui.mesh3d_panel + self.arrow_panel = self.ui.arrow_panel + self.force_plots = self.ui.force_plots + self.cal_panel = self.ui.cal_panel #: Built on first use so the pickers are parented where they belong. self.sensor_dialog = None - self.toolbar = ToolbarWidget(theme=self._theme) - root.addWidget(self.toolbar) - self.status_chip = self.toolbar.status_chip self.status_presenter = StatusPresenter(self.toolbar) - # Part B: the analysis pass a replay is served from. The surface sits directly - # under the rail, so "what is it doing and can I stop it" is answered where the - # transport is; it is hidden until a pass (or a cache hit) has something to say. - self.analysis_progress = AnalysisProgress(theme=self._theme) - root.addWidget(self.analysis_progress) - - self.content_splitter = QSplitter(Qt.Orientation.Horizontal) - self.mesh3d_panel = Mesh3dPanel(theme=self._theme) - self.arrow_panel = ArrowPanel(theme=self._theme) - # The island latch is *upstream of both views*: the 2D canvas paints the - # latched boundary, and the 3D pane's validity texture is that same mask. Two - # views, one decision about where the measurement is — see - # ``image_backend.DomainMaskLatch`` and ``Scene3DView.set_domain_latch``. + # Bind the shared mask and chart history before controllers can deliver frames. self.frame_view = FrameView(self.arrow_panel, self.mesh3d_panel) self.mesh3d_panel.set_domain_latch(self.frame_view.domain_mask) - self.force_plots = ForcePlotsPanel(theme=self._theme) - # The charts browse the whole session, not just their rolling buffer. self.force_plots.set_store(self.session_store) - self.cal_panel = CalPanel(theme=self._theme) - self.content_splitter.addWidget(self.mesh3d_panel) - self.content_splitter.addWidget(self.arrow_panel) - self.content_splitter.addWidget(self.force_plots) - self.content_splitter.addWidget(self.cal_panel) - self.content_splitter.setChildrenCollapsible(False) - root.addWidget(self.content_splitter, 1) # Playback drives the *chart cursor* (the playhead) instead of a slider # under the panels: the two views share one layout, and the transport is @@ -1037,162 +999,72 @@ class StudioMainWindow(QMainWindow): self.content_splitter.setSizes(sizes) def _build_menus(self) -> None: - menu_bar = self.menuBar() + self.menus = WindowMenus( + self, + show_cal=self._show_cal, + show_finger_3d=self._show_finger_3d, + show_fps=self._show_fps, + show_diagnostics=self.display_ctl.show_diagnostics, + settings_expanded=self.toolbar.settings_expanded(), + ) + # Existing controllers and tools use these names; each is the same Qt object. + self.file_menu = self.menus.file_menu + self.act_open_video = self.menus.act_open_video + self.act_reveal_config = self.menus.act_reveal_config + self.act_playback = self.menus.act_playback + self.act_playback_latest = self.menus.act_playback_latest + self.act_play = self.menus.act_play + self.session_menu = self.menus.session_menu + self.act_run = self.menus.act_run + self.act_stop = self.menus.act_stop + self.act_preview = self.menus.act_preview + self.act_reset = self.menus.act_reset + self.act_pause = self.menus.act_pause + self.analysis_menu = self.menus.analysis_menu + self.act_recompute = self.menus.act_recompute + self.act_analysis_setup = self.menus.act_analysis_setup + self.view_menu = self.menus.view_menu + self.act_cal_panel = self.menus.act_cal_panel + self.act_finger_3d = self.menus.act_finger_3d + self.act_scene3d_settings = self.menus.act_scene3d_settings + self.act_show_fps = self.menus.act_show_fps + self.act_show_diagnostics = self.menus.act_show_diagnostics + self.act_settings = self.menus.act_settings + self.act_plot_settings = self.menus.act_plot_settings + self.act_rotate_arrows = self.menus.act_rotate_arrows + self.act_reset_view = self.menus.act_reset_view + self.theme_menu = self.menus.theme_menu + self.theme_group = self.menus.theme_group + self.act_theme_dark = self.menus.act_theme_dark + self.act_theme_light = self.menus.act_theme_light + self.lang_menu = self.menus.lang_menu + self.lang_group = self.menus.lang_group + self.act_lang_zh = self.menus.act_lang_zh + self.act_lang_en = self.menus.act_lang_en + self.help_menu = self.menus.help_menu + self.act_about = self.menus.act_about - self.file_menu = menu_bar.addMenu("") - self.act_open_video = QAction(self) - self.file_menu.addAction(self.act_open_video) - self.act_reveal_config = QAction(self) + # Initial checks are set before any business handlers are connected. self.act_reveal_config.triggered.connect(self._on_reveal_config) - self.file_menu.addAction(self.act_reveal_config) - self.file_menu.addSeparator() - self.act_playback = QAction(self) - self.file_menu.addAction(self.act_playback) - self.act_playback_latest = QAction(self) - self.file_menu.addAction(self.act_playback_latest) - - # The transport is on the chart header; the space bar is the editing - # convention everyone's hands already know. - self.act_play = QAction(self) - self.act_play.setShortcut("Space") - self.act_play.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut) - self.addAction(self.act_play) - - # Acquisition session controls (run / preview / pause). Order on the bar: - # File | Session | Analysis | View | Help. - self.session_menu = menu_bar.addMenu("") - - self.act_run = QAction(self) - self.act_run.setShortcut("F5") self.act_run.triggered.connect(self._on_run_shortcut) - self.session_menu.addAction(self.act_run) - - self.act_stop = QAction(self) - self.act_stop.setShortcut("Esc") self.act_stop.triggered.connect(self._on_stop_shortcut) - self.session_menu.addAction(self.act_stop) - - self.act_preview = QAction(self) - self.act_preview.setShortcut("F6") self.act_preview.triggered.connect(self._on_preview_shortcut) - self.session_menu.addAction(self.act_preview) - - self.act_reset = QAction(self) - self.act_reset.setShortcut("Ctrl+R") self.act_reset.triggered.connect(self._on_reset) - self.session_menu.addAction(self.act_reset) - - # Pause has a shortcut as well as a rail button: an operator who is holding a - # sample still wants to stop the numbers moving without reaching for the mouse. - self.act_pause = QAction(self) - self.act_pause.setShortcut("F7") - self.act_pause.setCheckable(True) self.act_pause.toggled.connect(self._on_pause_action_toggled) - self.session_menu.addAction(self.act_pause) - - # Offline analysis of a recording. Separate from Session: that menu starts - # and stops a live device session. - self.analysis_menu = menu_bar.addMenu("") - # Serve / leave a stored analysis result (or run the pass when there is none). - self.act_recompute = QAction(self) - self.act_recompute.setCheckable(True) - self.act_recompute.setChecked(False) - self.analysis_menu.addAction(self.act_recompute) - # Reopen the setup dialog after the first pass (profile / calibration / reference). - self.act_analysis_setup = QAction(self) self.act_analysis_setup.triggered.connect(self.replay_ctl.on_analysis_setup) - self.analysis_menu.addAction(self.act_analysis_setup) - - self.view_menu = menu_bar.addMenu("") - self.act_cal_panel = QAction(self) - self.act_cal_panel.setCheckable(True) - self.act_cal_panel.setChecked(self._show_cal) - self.view_menu.addAction(self.act_cal_panel) - - self.act_finger_3d = QAction(self) - self.act_finger_3d.setCheckable(True) - self.act_finger_3d.setChecked(self._show_finger_3d) - self.view_menu.addAction(self.act_finger_3d) - - # Everything the 3D pane can be told lives in one dialog: the pane itself is for - # looking at (see ``Mesh3dPanel``). This is the menu way in; the pane's header - # has the gear, which is the same call. - self.act_scene3d_settings = QAction(self) self.act_scene3d_settings.triggered.connect(self._on_scene3d_settings) - self.view_menu.addAction(self.act_scene3d_settings) - - self.act_show_fps = QAction(self) - self.act_show_fps.setCheckable(True) - self.act_show_fps.setChecked(self._show_fps) self.act_show_fps.toggled.connect(self.display_ctl.on_show_fps_toggled) - self.view_menu.addAction(self.act_show_fps) - - # How much the panels explain themselves around their pictures. Checked *before* - # the signal is connected, so restoring the preference cannot look like a click. - self.act_show_diagnostics = QAction(self) - self.act_show_diagnostics.setCheckable(True) - self.act_show_diagnostics.setChecked(self.display_ctl.show_diagnostics) self.act_show_diagnostics.toggled.connect( self.display_ctl.on_show_diagnostics_toggled ) - self.view_menu.addAction(self.act_show_diagnostics) - - - self.act_settings = QAction(self) - self.act_settings.setCheckable(True) - self.act_settings.setChecked(self.toolbar.settings_expanded()) - self.view_menu.addAction(self.act_settings) - - self.act_plot_settings = QAction(self) self.act_plot_settings.triggered.connect(self._on_plot_settings) - self.view_menu.addAction(self.act_plot_settings) - - self.act_rotate_arrows = QAction(self) self.act_rotate_arrows.triggered.connect(self.display_ctl.on_rotate_arrows) - self.view_menu.addAction(self.act_rotate_arrows) - self.view_menu.addSeparator() - - # The graphics half of the old 重置: curves, window, cursor and the 3D camera. - # Separate because resetting the device tracker is not the same intention as - # wanting the views back, and only the first one belongs to the device (session menu). - self.act_reset_view = QAction(self) - self.act_reset_view.setShortcut("F8") self.act_reset_view.triggered.connect(self._on_reset_view) - self.view_menu.addAction(self.act_reset_view) - self.view_menu.addSeparator() - - self.theme_menu = self.view_menu.addMenu("") - self.theme_group = QActionGroup(self) - self.theme_group.setExclusive(True) - self.act_theme_dark = QAction(self) - self.act_theme_dark.setCheckable(True) - self.act_theme_light = QAction(self) - self.act_theme_light.setCheckable(True) - self.theme_group.addAction(self.act_theme_dark) - self.theme_group.addAction(self.act_theme_light) - self.theme_menu.addAction(self.act_theme_dark) - self.theme_menu.addAction(self.act_theme_light) self.act_theme_dark.triggered.connect(lambda: self._set_theme("dark")) self.act_theme_light.triggered.connect(lambda: self._set_theme("light")) - - self.lang_menu = self.view_menu.addMenu("") - self.lang_group = QActionGroup(self) - self.lang_group.setExclusive(True) - self.act_lang_zh = QAction(self) - self.act_lang_zh.setCheckable(True) - self.act_lang_en = QAction(self) - self.act_lang_en.setCheckable(True) - self.lang_group.addAction(self.act_lang_zh) - self.lang_group.addAction(self.act_lang_en) - self.lang_menu.addAction(self.act_lang_zh) - self.lang_menu.addAction(self.act_lang_en) self.act_lang_zh.triggered.connect(lambda: self._set_language("zh_CN")) self.act_lang_en.triggered.connect(lambda: self._set_language("en_US")) - - self.help_menu = menu_bar.addMenu("") - self.act_about = QAction(self) self.act_about.triggered.connect(self._show_about) - self.help_menu.addAction(self.act_about) # The replay entries exist now: hand them over, and the controller connects # them (it owns what they mean — see ``ReplayController.bind_actions``). @@ -1216,10 +1088,7 @@ class StudioMainWindow(QMainWindow): self._sync_menu_checks() def _sync_menu_checks(self) -> None: - self.act_theme_dark.setChecked(self._theme != "light") - self.act_theme_light.setChecked(self._theme == "light") - self.act_lang_zh.setChecked(self._language.startswith("zh")) - self.act_lang_en.setChecked(not self._language.startswith("zh")) + self.menus.sync_appearance(theme=self._theme, language=self._language) # The view entries are the layout's; the theme/language ones are the window's. self.layout_ctl.sync_view_checks() self.act_recompute.setEnabled(self.replay_ctl.can_recompute()) @@ -1372,51 +1241,11 @@ class StudioMainWindow(QMainWindow): def retranslate(self) -> None: self.setWindowTitle(t("app.title")) - self.file_menu.setTitle(t("menu.file")) - self.act_open_video.setText(t("menu.open_video")) - self.act_reveal_config.setText(t("menu.reveal_config")) - self.act_playback.setText(t("menu.playback")) - self.act_playback_latest.setText(t("menu.playback_latest")) - self.session_menu.setTitle(t("menu.session")) - self.act_run.setText(t("menu.run")) - self.act_stop.setText(t("menu.stop")) - self.act_preview.setText(t("menu.preview")) - # Ctrl+R is the rail's reset on a second surface, so its wording follows the - # view the same way the button's does (see ``_sync_reset_action``). + self.menus.retranslate() + # Reset wording and pause state depend on the active workflow. self._sync_reset_action() - self.act_pause.setText(t("menu.pause")) self._sync_pause_action() - self.analysis_menu.setTitle(t("menu.analysis")) - self.act_recompute.setText(t("menu.recompute")) - self.act_recompute.setToolTip(t("menu.recompute_tip")) - self.act_analysis_setup.setText(t("menu.analysis_setup")) - self.act_analysis_setup.setToolTip(t("menu.analysis_setup_tip")) - self.view_menu.setTitle(t("menu.view")) - self.act_cal_panel.setText(t("menu.calibration_panel")) - self.act_finger_3d.setText(t("menu.finger_3d")) - self.act_scene3d_settings.setText(t("menu.scene3d_settings")) - self.act_show_fps.setText(t("menu.show_fps")) - self.act_show_diagnostics.setText(t("menu.show_diagnostics")) - self.act_settings.setText(t("menu.settings")) - self.act_plot_settings.setText(t("menu.plot_settings")) - self.act_rotate_arrows.setText(t("menu.rotate_arrows")) - self.act_reset_view.setText(t("menu.reset_view")) - self.act_reset_view.setToolTip(t("menu.reset_view_tip")) - self.theme_menu.setTitle(t("menu.theme")) - self.act_theme_dark.setText(t("menu.theme_dark")) - self.act_theme_light.setText(t("menu.theme_light")) - self.lang_menu.setTitle(t("menu.language")) - self.act_lang_zh.setText(t("menu.lang_zh")) - self.act_lang_en.setText(t("menu.lang_en")) - self.help_menu.setTitle(t("menu.help")) - self.act_about.setText(t("menu.about")) - self.toolbar.retranslate() - self.sensor_picker.retranslate() - self.mesh3d_panel.retranslate() - self.arrow_panel.retranslate() - self.force_plots.retranslate() - self.cal_panel.retranslate() - self.brand_bar.retranslate() + self.ui.retranslate() self._refresh_status_chip() self._refresh_status_rail() self._refresh_record_state() @@ -1490,12 +1319,7 @@ class StudioMainWindow(QMainWindow): app = QApplication.instance() if app is not None: app.setStyleSheet(build_qss(theme)) - self.force_plots.set_theme(theme) - self.cal_panel.set_theme(theme) - self.brand_bar.set_theme(theme) - self.toolbar.set_theme(theme) - self.arrow_panel.set_theme(theme) - self.mesh3d_panel.set_theme(theme) + self.ui.set_theme(theme) self._refresh_status_chip() self._refresh_status_rail() self._refresh_record_state() diff --git a/apps/studio/src/orisys_studio/ui/window_menus.py b/apps/studio/src/orisys_studio/ui/window_menus.py index 3b54016..bece91c 100644 --- a/apps/studio/src/orisys_studio/ui/window_menus.py +++ b/apps/studio/src/orisys_studio/ui/window_menus.py @@ -1,4 +1,4 @@ -"""Shell menus and actions prepared for S2, with no business signal handlers. +"""Shell menus and actions, with no business signal handlers. All actions have the supplied QMainWindow as their Qt parent. The caller binds commands after applying initial checks, and owns reset-target wording, pause diff --git a/apps/studio/src/orisys_studio/ui/window_ui.py b/apps/studio/src/orisys_studio/ui/window_ui.py index 48d537d..daa192b 100644 --- a/apps/studio/src/orisys_studio/ui/window_ui.py +++ b/apps/studio/src/orisys_studio/ui/window_ui.py @@ -1,4 +1,4 @@ -"""Central shell widgets prepared for S2, without application wiring. +"""Central shell widgets; application wiring belongs to the main window. Construct on the GUI thread. The caller owns controllers, frame delivery, the shared domain latch, chart store binding and preference restoration. Nothing diff --git a/apps/studio/tests/test_window_surfaces.py b/apps/studio/tests/test_window_surfaces.py index 06e59a4..b38a179 100644 --- a/apps/studio/tests/test_window_surfaces.py +++ b/apps/studio/tests/test_window_surfaces.py @@ -1,4 +1,4 @@ -"""Prepared shell surfaces work without the main window or business wiring.""" +"""Shell surfaces work independently and retain their behavior when wired.""" import pytest from orisys_app_shared.i18n import get_translator, set_locale, t @@ -164,3 +164,73 @@ def test_text_refresh_preserves_controller_owned_action_state(window): assert ui.act_reset.text() == "context-dependent reset" assert ui.act_pause.isChecked() assert not ui.act_run.isEnabled() + + +def test_shell_uses_the_surface_widgets_and_actions(make_window): + shell = make_window() + assert shell.centralWidget() is shell.ui.central + for name in ( + "brand_bar", "sensor_picker", "toolbar", "status_chip", "analysis_progress", + "content_splitter", "mesh3d_panel", "arrow_panel", "force_plots", "cal_panel", + ): + assert getattr(shell, name) is getattr(shell.ui, name) + assert shell.file_menu is shell.menus.file_menu + assert shell.act_play is shell.menus.act_play + assert shell.act_reset is shell.menus.act_reset + assert shell.mesh3d_panel.view is None + assert not shell.session.is_running + + +def test_shell_menu_commands_dispatch_once(make_window, monkeypatch): + from orisys_studio.app_window import StudioMainWindow + + calls = [] + + def handler(name): + return lambda self: calls.append(name) + + commands = { + "run": "_on_run_shortcut", + "stop": "_on_stop_shortcut", + "preview": "_on_preview_shortcut", + "reset": "_on_reset", + "reset_view": "_on_reset_view", + } + for name, method in commands.items(): + monkeypatch.setattr(StudioMainWindow, method, handler(name)) + shell = make_window() + calls.clear() + for name in commands: + action = getattr(shell.menus, f"act_{name}") + # No device is attached; exercise dispatch without availability policy. + action.setEnabled(True) + action.trigger() + assert calls == list(commands) + + +def test_shell_appearance_actions_refresh_widgets_and_menus(make_window, qt_app): + original_locale = get_translator().locale + original_stylesheet = qt_app.styleSheet() + shell = make_window() + try: + background = shell.force_plots.plot_widget.backgroundBrush().color() + shell.menus.act_theme_light.trigger() + assert shell.prefs["theme"] == "light" + assert shell.force_plots.plot_widget.backgroundBrush().color() != background + assert shell.menus.act_theme_light.isChecked() + assert not shell.menus.act_theme_dark.isChecked() + shell.menus.act_lang_en.trigger() + assert shell.prefs["language"] == "en_US" + assert shell.file_menu.title() == t("menu.file") + assert shell.brand_bar.btn_live.text() == t("brand.nav_live") + assert shell.act_reset.text() == t("menu.reset") + assert shell.toolbar.btn_reset.text() == t("toolbar.reset_sensor") + shell.layout_ctl.apply_preset("replay") + assert shell.act_reset.text() == t("menu.reset_display") + assert shell.toolbar.btn_reset.text() == t("toolbar.reset_display") + assert shell.menus.act_lang_en.isChecked() + assert not shell.menus.act_lang_zh.isChecked() + assert shell.mesh3d_panel.view is None + finally: + set_locale(original_locale) + qt_app.setStyleSheet(original_stylesheet) diff --git a/docs/architecture/studio-architecture.md b/docs/architecture/studio-architecture.md index 8d817cb..f0d4ce7 100644 --- a/docs/architecture/studio-architecture.md +++ b/docs/architecture/studio-architecture.md @@ -1,14 +1,14 @@ # Studio 软件架构 -本文说明已接线的 S1 和 2026-10-19 准备但尚未接线的 S2;后续工作和验收由 +本文说明已接线的 S1–S2;后续工作和验收由 [分步重构计划](studio-window-refactor-plan.zh.md) 维护。下方旧扫描图表保留为历史证据, 不能用其中的行数或“已完成”判断当前代码。 ## 当前职责与数据流 -`app_window.py::StudioMainWindow` 创建控件和控制器并连接信号。S1 批次从 3,181 行减至 -3,052 行,这是当日指标;当前另有并行标定修改。窗口仍承担菜单、预览、标定交互、 -分析配置和运行协调,尚未完成薄窗口目标。 +`app_window.py::StudioMainWindow` 装配界面、控制器并连接信号。2026-10-19 的 S2 接线 +从 3,070 行减至 2,894 行。窗口仍承担业务接线、预览、标定交互、分析配置和运行协调, +尚未完成薄窗口目标。 `status_presenter.py::StatusPresenter` 只持有工具栏,不持有窗口或会话控制器。 窗口在 GUI 线程收集一次性的 `RailState`,Presenter 负责 phase、来源、标定、有效性及 @@ -28,6 +28,8 @@ graph TD P --> T[ToolbarWidget 状态轨] W --> A[AcquirePresenter 实时呈现] W --> V[FrameView 二维与共享深度范围] + W --> U[WindowUi 控件与布局] + W --> M[WindowMenus 菜单与快捷键] ``` 会话状态仍属于 `SessionController`,录制状态仍属于 `RecordingCoordinator`。 @@ -40,15 +42,16 @@ graph TD 执行结果、环境及尚未验证范围记录在实施计划。状态转发暂留用于现有调用方,后续按职责迁移, 不增加新的全局状态容器或将剩余逻辑全部并入回放控制器。 -## S2 准备模块,尚未接线 +## S2 界面构建与接线 `ui/window_ui.py::WindowUi` 创建控件和布局,提供局部主题、文字更新; `ui/window_menus.py::WindowMenus` 创建菜单、快捷键和勾选初值,不绑定业务回调。 -当前运行图不包含这两个模块,主窗口仍使用原构建路径,标定读取和应用路径由并行任务维护。 +窗口通过 `ui`、`menus` 使用它们,旧控件和 action 属性保留同一对象引用供控制器和工具访问。 -独立检查由 `test_window_surfaces.py` 负责。接线时仍由窗口装配 FrameView、共享 domain latch、 -曲线 store 和控制器信号;reset 文字及操作可用性保留原负责人。具体顺序见实施计划, -不能把独立模块测试通过等同于主窗口迁移完成。 +窗口在控制器接线前装配 FrameView、共享 domain latch 和曲线 store;菜单初值先于业务 +回调绑定。偏好保存、全局主题与语言、reset 文字、pause 状态和操作可用性仍由原负责人管理。 +标定读取与应用路径保持原实现。`test_window_surfaces.py` 覆盖独立模块与主窗口接线; +布局、传感器切换和回放由现有集成测试覆盖,结果及验证限制见实施计划。 ## 历史测量的适用范围 diff --git a/docs/architecture/studio-window-refactor-plan.zh.md b/docs/architecture/studio-window-refactor-plan.zh.md index afbffb8..1eb48d0 100644 --- a/docs/architecture/studio-window-refactor-plan.zh.md +++ b/docs/architecture/studio-window-refactor-plan.zh.md @@ -41,10 +41,10 @@ ### S2:界面构建 -- [x] 准备 `ui/window_ui.py` 和 `ui/window_menus.py`,以独立窗口验证,尚未接入运行路径。 -- [ ] 提取 `ui/window_ui.py` 的控件和布局构建,保持控件身份、顺序及 parent。 -- [ ] 单独迁移菜单和 QAction 创建;由窗口显式连接功能操作。 -- [ ] 迁移对应文字与主题更新,保留必要属性兼容入口。 +- [x] 准备 `ui/window_ui.py` 和 `ui/window_menus.py`,先以独立窗口验证。 +- [x] 接入 `ui/window_ui.py` 的控件和布局构建,保持控件身份、顺序及 parent。 +- [x] 迁移菜单和 QAction 创建;由窗口显式连接功能操作。 +- [x] 迁移对应文字与主题更新,保留必要属性兼容入口。 依赖 S1。涉及主窗口、UI 模块及实际调用方;面板构建和菜单构建分两批验收。 验证:`test_ui_elements`、`test_view_layout`、`test_sensor_switch_window`、主题与语言检查。 @@ -95,6 +95,38 @@ 每个迁移步骤运行对应检查;可交付批次运行 `tools/gate.ps1 -Task studio-window-refactor`。 文档使用工作区 `scripts/doc_lint.py` 和 `scripts/doc_count.py`,不扩大基线。 +### 2026-10-19:S2 接线 + +以 `main` 的 `c6c16df` 为基准,接入已准备的两个 UI 模块;主窗口从 3,070 行减至 +2,894 行。10 个控件和 35 个菜单、action 属性保留同一对象引用;18 处菜单业务回调及 +控制器绑定仍在窗口。主题与文字更新委托 UI 模块,reset 目标、pause 状态仍沿用原逻辑。 + +FrameView、共享 domain latch 和曲线 store 在控制器接线前绑定,偏好恢复顺序不变。 +只改动窗口的构造、菜单构建、菜单勾选、文字和主题刷新五个方法;其余方法的 AST 一致, +最新标定读取、应用和关闭路径未迁移。S3–S6 保持待实施。 + +改动前定向检查为 46 passed;接线后加上三个集成检查为 49 passed。 +覆盖控件身份、菜单单次调用、主题与语言刷新、实时与回放的 reset 目标,以及既有布局、 +暂停和标定对话框。首次新增断言误将菜单与按钮的不同文案视为相同,已按原契约修正。 +串行门禁的 lint 通过,UI 检查为 632 checks、0 failures;共享层为 461 passed、 +18 skipped、3 deselected、15 failed,涉及 SDK 映射文件缺失及几何尺寸不一致。 +Studio 在垃圾回收期间访问冲突退出,无 JUnit 报告。整套门禁未通过,原因尚未完全确认。 + +扩展界面检查为 131 passed、1 failed,失败是分析停止后的即时状态断言。 +该用例及崩溃所在的三个布局场景,在旧、新主窗口隔离运行均为 4 passed,未复现整套异常。 +旧主窗口整套对照长时间无进度后已中断,不能据此判定原因。未修改失败断言或扩大基线。 +临时禁用局部主题刷新时,新增测试按预期失败;仅在测试进程注入,源码未改动。 + +环境为 Windows、Python 3.10.19、offscreen Qt;真实设备、可见桌面和硬件 GL 未验证。 +文档检查无新增问题,原架构长段落债务仍保留。证据位于 +`.scratch/studio-window-refactor/20261019-095028/` 和 `gate-20261019T015301Z/`。 +另一个进程的 Autocal 修改含暂存项,全部排除;本批六个文件未暂存,根仓库未修改。 + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File tools/gate.ps1 -Task studio-window-refactor -LintBase HEAD -Serial +& .\.venv-build\Scripts\python.exe -m pytest apps/studio/tests/test_ui_smoke.py apps/studio/tests/test_ui_elements.py apps/studio/tests/test_sensor_switch_window.py -m 'not timing' -o faulthandler_timeout=60 -p no:cacheprovider --basetemp=D:\orisys_ws\orisys-app\.scratch\studio-window-refactor\20261019-095028\pytest-ui-focused --junitxml=D:\orisys_ws\orisys-app\.scratch\studio-window-refactor\20261019-095028\ui-focused.xml +``` + ### 2026-10-19:S2 模块准备,接线延期 用户要求避开另一个进程正在修改的标定配置读取和应用路径,本批只新增模块及独立测试,