refactor: 准备独立的 Studio 控件与菜单构建模块

让界面构建可脱离主窗口独立测试。界面与绑定检查 13 项通过,运行入口尚未接线。
This commit is contained in:
ykcheungab
2026-10-19 09:48:26 +08:00
parent 31750f0f84
commit c6c16df753
5 changed files with 496 additions and 4 deletions
@@ -0,0 +1,207 @@
"""Shell menus and actions prepared for S2, 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
state and action availability. Construction never calls the window's methods
other than QMainWindow's menu/action API.
"""
from __future__ import annotations
from orisys_app_shared.i18n import t
from PySide6.QtCore import Qt
from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtWidgets import QMainWindow
class WindowMenus:
"""Create the existing shell actions independently of any application controller."""
def __init__(
self,
window: QMainWindow,
*,
show_cal: bool,
show_finger_3d: bool,
show_fps: bool,
show_diagnostics: bool,
settings_expanded: bool,
) -> None:
menu_bar = window.menuBar()
self.file_menu = menu_bar.addMenu("")
self.act_open_video = QAction(window)
self.file_menu.addAction(self.act_open_video)
self.act_reveal_config = QAction(window)
self.file_menu.addAction(self.act_reveal_config)
self.file_menu.addSeparator()
self.act_playback = QAction(window)
self.file_menu.addAction(self.act_playback)
self.act_playback_latest = QAction(window)
self.file_menu.addAction(self.act_playback_latest)
# Space is a window action rather than an entry in a menu.
self.act_play = QAction(window)
self.act_play.setShortcut("Space")
self.act_play.setShortcutContext(Qt.ShortcutContext.ApplicationShortcut)
window.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(window)
self.act_run.setShortcut("F5")
self.session_menu.addAction(self.act_run)
self.act_stop = QAction(window)
self.act_stop.setShortcut("Esc")
self.session_menu.addAction(self.act_stop)
self.act_preview = QAction(window)
self.act_preview.setShortcut("F6")
self.session_menu.addAction(self.act_preview)
self.act_reset = QAction(window)
self.act_reset.setShortcut("Ctrl+R")
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(window)
self.act_pause.setShortcut("F7")
self.act_pause.setCheckable(True)
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(window)
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(window)
self.analysis_menu.addAction(self.act_analysis_setup)
self.view_menu = menu_bar.addMenu("")
self.act_cal_panel = QAction(window)
self.act_cal_panel.setCheckable(True)
self.act_cal_panel.setChecked(show_cal)
self.view_menu.addAction(self.act_cal_panel)
self.act_finger_3d = QAction(window)
self.act_finger_3d.setCheckable(True)
self.act_finger_3d.setChecked(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(window)
self.view_menu.addAction(self.act_scene3d_settings)
self.act_show_fps = QAction(window)
self.act_show_fps.setCheckable(True)
self.act_show_fps.setChecked(show_fps)
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(window)
self.act_show_diagnostics.setCheckable(True)
self.act_show_diagnostics.setChecked(show_diagnostics)
self.view_menu.addAction(self.act_show_diagnostics)
self.act_settings = QAction(window)
self.act_settings.setCheckable(True)
self.act_settings.setChecked(settings_expanded)
self.view_menu.addAction(self.act_settings)
self.act_plot_settings = QAction(window)
self.view_menu.addAction(self.act_plot_settings)
self.act_rotate_arrows = QAction(window)
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(window)
self.act_reset_view.setShortcut("F8")
self.view_menu.addAction(self.act_reset_view)
self.view_menu.addSeparator()
self.theme_menu = self.view_menu.addMenu("")
self.theme_group = QActionGroup(window)
self.theme_group.setExclusive(True)
self.act_theme_dark = QAction(window)
self.act_theme_dark.setCheckable(True)
self.act_theme_light = QAction(window)
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.lang_menu = self.view_menu.addMenu("")
self.lang_group = QActionGroup(window)
self.lang_group.setExclusive(True)
self.act_lang_zh = QAction(window)
self.act_lang_zh.setCheckable(True)
self.act_lang_en = QAction(window)
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.help_menu = menu_bar.addMenu("")
self.act_about = QAction(window)
self.help_menu.addAction(self.act_about)
def retranslate(self) -> None:
"""Update static labels; the caller refreshes context-dependent reset wording."""
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"))
self.act_pause.setText(t("menu.pause"))
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"))
def sync_appearance(self, *, theme: str, language: str) -> None:
"""Mirror appearance preferences without saving them or changing the application."""
self.act_theme_dark.setChecked(theme != "light")
self.act_theme_light.setChecked(theme == "light")
self.act_lang_zh.setChecked(language.startswith("zh"))
self.act_lang_en.setChecked(not language.startswith("zh"))
@@ -0,0 +1,78 @@
"""Central shell widgets prepared for S2, without application wiring.
Construct on the GUI thread. The caller owns controllers, frame delivery, the
shared domain latch, chart store binding and preference restoration. Nothing
here starts acquisition, applies calibration or creates the lazy 3D view.
"""
from __future__ import annotations
from orisys_app_shared.paths import logo_path
from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon
from PySide6.QtWidgets import QMainWindow, QSplitter, QVBoxLayout, QWidget
from orisys_studio.ui.analysis_progress import AnalysisProgress
from orisys_studio.ui.arrow_panel import ArrowPanel
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.sensor_picker import SensorPickerWidget
from orisys_studio.ui.toolbar import ToolbarWidget
class WindowUi:
"""Own widget references, leaving all workflow decisions to the caller."""
def __init__(self, window: QMainWindow, *, theme: str) -> None:
icon = QIcon(str(logo_path()))
if not icon.isNull():
window.setWindowIcon(icon)
self.central = QWidget()
window.setCentralWidget(self.central)
root = QVBoxLayout(self.central)
root.setContentsMargins(10, 10, 10, 10)
root.setSpacing(8)
self.brand_bar = BrandBar(theme=theme)
root.addWidget(self.brand_bar)
# The sensor dialog adopts this picker later; it is not in the central layout.
self.sensor_picker = SensorPickerWidget()
self.toolbar = ToolbarWidget(theme=theme)
root.addWidget(self.toolbar)
self.status_chip = self.toolbar.status_chip
self.analysis_progress = AnalysisProgress(theme=theme)
root.addWidget(self.analysis_progress)
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
self.mesh3d_panel = Mesh3dPanel(theme=theme)
self.arrow_panel = ArrowPanel(theme=theme)
self.force_plots = ForcePlotsPanel(theme=theme)
self.cal_panel = CalPanel(theme=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)
def set_theme(self, theme: str) -> None:
"""Refresh local surfaces; the application stylesheet remains the caller's."""
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)
def retranslate(self) -> None:
"""Refresh widget text without changing the application locale or state."""
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()
+166
View File
@@ -0,0 +1,166 @@
"""Prepared shell surfaces work without the main window or business wiring."""
import pytest
from orisys_app_shared.i18n import get_translator, set_locale, t
from orisys_app_shared.sensors import scan_sensor_catalog
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QMainWindow
from orisys_studio.ui import sensor_picker as picker_module
from orisys_studio.ui.window_menus import WindowMenus
from orisys_studio.ui.window_ui import WindowUi
@pytest.fixture
def window(qt_app):
original_locale = get_translator().locale
widget = QMainWindow()
yield widget
widget.close()
widget.deleteLater()
set_locale(original_locale)
@pytest.fixture
def surface(window, tmp_path, monkeypatch):
catalog = scan_sensor_catalog(
shipped_root=tmp_path / "shipped",
user_root=tmp_path / "user",
)
monkeypatch.setattr(picker_module, "scan_sensor_catalog", lambda: catalog)
ui = WindowUi(window, theme="dark")
yield ui
# The picker is intentionally not parented until a sensor dialog adopts it.
ui.sensor_picker.close()
ui.sensor_picker.deleteLater()
def menus(window, **changes):
values = dict(
show_cal=False,
show_finger_3d=True,
show_fps=True,
show_diagnostics=False,
settings_expanded=False,
)
values.update(changes)
return WindowMenus(window, **values)
def test_central_surface_preserves_panel_order_and_ownership(window, surface):
assert window.centralWidget() is surface.central
layout = surface.central.layout()
assert [layout.itemAt(i).widget() for i in range(layout.count())] == [
surface.brand_bar,
surface.toolbar,
surface.analysis_progress,
surface.content_splitter,
]
assert layout.spacing() == 8
margins = layout.contentsMargins()
assert (margins.left(), margins.top(), margins.right(), margins.bottom()) == (
10,
) * 4
splitter = surface.content_splitter
panels = [
surface.mesh3d_panel,
surface.arrow_panel,
surface.force_plots,
surface.cal_panel,
]
assert splitter.orientation() == Qt.Orientation.Horizontal
assert [splitter.widget(i) for i in range(splitter.count())] == panels
assert all(panel.parentWidget() is splitter for panel in panels)
assert not splitter.childrenCollapsible()
assert surface.sensor_picker.parentWidget() is None
assert surface.status_chip is surface.toolbar.status_chip
def test_surface_refresh_keeps_3d_lazy_and_global_theme_unchanged(qt_app, surface):
stylesheet = qt_app.styleSheet()
plot_background = surface.force_plots.plot_widget.backgroundBrush().color()
surface.set_theme("light")
surface.retranslate()
assert surface.force_plots.plot_widget.backgroundBrush().color() != plot_background
assert qt_app.styleSheet() == stylesheet
assert surface.mesh3d_panel.view is None
assert surface.brand_bar.btn_live.text() == t("brand.nav_live")
@pytest.mark.parametrize("locale", ["zh_CN", "en_US"])
def test_menu_order_and_labels_match_the_shell(window, locale):
set_locale(locale)
ui = menus(window)
ui.retranslate()
assert [action.text() for action in window.menuBar().actions()] == [
t("menu.file"),
t("menu.session"),
t("menu.analysis"),
t("menu.view"),
t("menu.help"),
]
assert ui.session_menu.actions() == [
ui.act_run,
ui.act_stop,
ui.act_preview,
ui.act_reset,
ui.act_pause,
]
assert ui.act_analysis_setup.toolTip() == t("menu.analysis_setup_tip")
assert ui.act_recompute.toolTip() == t("menu.recompute_tip")
def test_shortcuts_are_available_without_any_business_host(window):
ui = menus(window)
assert [
action.shortcut().toString()
for action in (
ui.act_run,
ui.act_stop,
ui.act_preview,
ui.act_reset,
ui.act_pause,
ui.act_reset_view,
ui.act_play,
)
] == ["F5", "Esc", "F6", "Ctrl+R", "F7", "F8", "Space"]
assert ui.act_play.shortcutContext() == Qt.ShortcutContext.ApplicationShortcut
assert ui.act_play in window.actions()
seen = []
ui.act_run.triggered.connect(lambda: seen.append("run"))
ui.act_run.trigger()
assert seen == ["run"]
# These actions have no device, calibration or analysis handlers to invoke yet.
ui.act_reset.trigger()
ui.act_recompute.trigger()
assert not hasattr(window, "session")
def test_menu_initial_checks_and_appearance_refresh_are_separate(window):
ui = menus(window, show_cal=True, show_fps=False, settings_expanded=True)
assert ui.act_cal_panel.isChecked()
assert ui.act_finger_3d.isChecked()
assert not ui.act_show_fps.isChecked()
assert not ui.act_show_diagnostics.isChecked()
assert ui.act_settings.isChecked()
assert not ui.act_recompute.isChecked()
ui.sync_appearance(theme="light", language="en_US")
assert ui.act_theme_light.isChecked() and not ui.act_theme_dark.isChecked()
assert ui.act_lang_en.isChecked() and not ui.act_lang_zh.isChecked()
ui.act_theme_dark.trigger()
assert ui.act_theme_dark.isChecked() and not ui.act_theme_light.isChecked()
ui.act_lang_zh.trigger()
assert ui.act_lang_zh.isChecked() and not ui.act_lang_en.isChecked()
assert ui.act_cal_panel.isChecked()
def test_text_refresh_preserves_controller_owned_action_state(window):
ui = menus(window)
ui.act_reset.setText("context-dependent reset")
ui.act_pause.setChecked(True)
ui.act_run.setEnabled(False)
ui.retranslate()
ui.sync_appearance(theme="dark", language="zh_CN")
assert ui.act_reset.text() == "context-dependent reset"
assert ui.act_pause.isChecked()
assert not ui.act_run.isEnabled()