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()