接入 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)