接入 Studio 控件与菜单构建模块

将界面构建从主窗口移出,保留现有业务回调与标定路径。验证结果和完整门禁限制见 docs/architecture/studio-window-refactor-plan.zh.md。
This commit is contained in:
ykcheungab
2026-10-19 10:14:58 +08:00
parent 22658dbfc1
commit d477a4d8b7
6 changed files with 189 additions and 260 deletions
+68 -244
View File
@@ -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()
@@ -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
@@ -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
+71 -1
View File
@@ -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)
+12 -9
View File
@@ -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` 覆盖独立模块与主窗口接线;
布局、传感器切换和回放由现有集成测试覆盖,结果及验证限制见实施计划。
## 历史测量的适用范围
@@ -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 模块准备,接线延期
用户要求避开另一个进程正在修改的标定配置读取和应用路径,本批只新增模块及独立测试,