diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 0f4ecd5..9625e92 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -46,6 +46,7 @@ from orisys_app_shared.record.sample import to_record_sample from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs from orisys_studio.replay import ReplayEngine from orisys_studio.replay_controller import ReplayController +from orisys_studio.view_layout import ViewLayoutController from orisys_studio import session_metadata from orisys_studio.session_controller import SessionController from orisys_studio.scene3d.prefs import settings_to_prefs @@ -153,10 +154,8 @@ class StudioMainWindow(QMainWindow): self.prefs: dict[str, Any] = dict(prefs or {}) self._theme = str(self.prefs.get("theme") or "dark") self._language = str(self.prefs.get("language") or "zh_CN") - self._show_cal = bool(self.prefs.get("show_cal_panel", False)) - preset = str(self.prefs.get("view_preset") or "custom") - self._view_preset = preset if preset in VIEW_PRESETS else "custom" - self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True)) + # The view preset, the calibration panel and the 3D pane's switch are the layout + # controller's state; the window reads and writes them through the shims below. self._show_fps = bool(self.prefs.get("show_fps", False)) # Whether a recompute pass is running is the replay controller's state; the # window reads and writes it through the ``_recompute_running`` shim below. @@ -225,9 +224,6 @@ class StudioMainWindow(QMainWindow): root.setSpacing(8) self.brand_bar = BrandBar(theme=self._theme) - self.brand_bar.live_requested.connect(self._on_live_requested) - self.brand_bar.playback_requested.connect(self._on_replay_requested) - self.brand_bar.calibration_requested.connect(self._on_calibration_requested) root.addWidget(self.brand_bar) # The sensor picker itself lives in the sensor dialog; the window owns it @@ -253,16 +249,13 @@ class StudioMainWindow(QMainWindow): self.content_splitter.addWidget(self.cal_panel) self.content_splitter.setChildrenCollapsible(False) root.addWidget(self.content_splitter, 1) - # The pane's own switch is the same state as 视图 ▸ 三维网格. The window owns - # the state; the checkbox is a second view of it (see `_on_3d_pane_toggled`). - self.mesh3d_panel.enabledChanged.connect(self._on_3d_pane_toggled) # Playback drives the *chart cursor* (the playhead) instead of a slider # under the panels: the two views share one layout, and the transport is # the rail's primary button — 「运行」while measuring, 「回放」while # replaying (see ``_on_run_toggled`` / ``toolbar.set_run_role``). - # The replay policy needs the panels (built above) and the menus (built below): - # it is created here, between the two. + # The replay *policy* around the engine is a controller; it needs the panels + # built above (the menus it also drives are handed to it by ``_build_menus``). self.replay_ctl = ReplayController( engine=self.replay, host=self, @@ -273,6 +266,37 @@ class StudioMainWindow(QMainWindow): parent=self, ) self.replay_ctl.wire() + # Which panels are on screen, in what arrangement, in which view: also a + # controller, so the window owns the widgets rather than the furniture's state. + # Its initial values come from the preferences, so nothing is duplicated here. + self.layout_ctl = ViewLayoutController( + host=self, + splitter=self.content_splitter, + mesh_panel=self.mesh3d_panel, + image_panel=self.arrow_panel, + charts=self.force_plots, + cal_panel=self.cal_panel, + sensor_picker=self.sensor_picker, + brand_bar=self.brand_bar, + toolbar=self.toolbar, + replay=self.replay_ctl, + presets=VIEW_PRESETS, + shows_3d_in=VIEW_SHOWS_3D, + splitter_sizes=VIEW_SPLITTER_SIZES, + default_splitter_sizes=DEFAULT_SPLITTER_SIZES, + min_panel_widths=MIN_PANEL_WIDTHS, + default_3d_pane_width=DEFAULT_3D_PANE_WIDTH, + parent=self, + ) + # The pane's own switch is the same state as 视图 ▸ 三维网格: the layout + # controller owns that state, and this is its second view. + self.mesh3d_panel.enabledChanged.connect(self.layout_ctl.on_3d_pane_toggled) + # The nav entries switch views, which is the layout controller's business. + self.brand_bar.live_requested.connect(self.layout_ctl.on_live_requested) + self.brand_bar.playback_requested.connect(self.layout_ctl.on_replay_requested) + self.brand_bar.calibration_requested.connect( + self.layout_ctl.on_calibration_requested + ) self.setStatusBar(_NoticeBar()) # The status bar carries **one-off notices** only (a recording finished, a @@ -693,7 +717,7 @@ class StudioMainWindow(QMainWindow): self.toolbar.record_toggled.connect(self._on_record_toggled) self.toolbar.reset_clicked.connect(self._on_reset) self.toolbar.units_changed.connect(self._on_units_changed) - self.toolbar.settings_toggled.connect(self._on_settings_toggled) + self.toolbar.settings_toggled.connect(self.layout_ctl.on_settings_toggled) def _wire_sensor_picker(self) -> None: self.sensor_picker.sensor_type_changed.connect(self._on_sensor_type_changed) @@ -841,13 +865,11 @@ class StudioMainWindow(QMainWindow): self.act_cal_panel = QAction(self) self.act_cal_panel.setCheckable(True) self.act_cal_panel.setChecked(self._show_cal) - self.act_cal_panel.toggled.connect(self._on_cal_panel_toggled) 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.act_finger_3d.toggled.connect(self._on_finger_3d_toggled) self.view_menu.addAction(self.act_finger_3d) self.act_show_fps = QAction(self) @@ -859,7 +881,6 @@ class StudioMainWindow(QMainWindow): self.act_settings = QAction(self) self.act_settings.setCheckable(True) self.act_settings.setChecked(self.toolbar.settings_expanded()) - self.act_settings.toggled.connect(self._on_settings_action_toggled) self.view_menu.addAction(self.act_settings) self.act_plot_settings = QAction(self) @@ -942,6 +963,13 @@ class StudioMainWindow(QMainWindow): play=self.act_play, recompute=self.act_recompute, ) + # The view-menu entries the layout owns; it connects them, because it owns what + # "calibration panel visible" and "3D pane on" mean. + self.layout_ctl.bind_actions( + cal_panel=self.act_cal_panel, + finger_3d=self.act_finger_3d, + settings=self.act_settings, + ) self._sync_menu_checks() def _sync_menu_checks(self) -> None: @@ -949,14 +977,8 @@ class StudioMainWindow(QMainWindow): 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.act_cal_panel.blockSignals(True) - self.act_cal_panel.setChecked(self._show_cal) - self.act_cal_panel.blockSignals(False) - is_finger = self.sensor_picker.sensor_type() == "finger" - self.act_finger_3d.setEnabled(is_finger) - self.act_finger_3d.blockSignals(True) - self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False) - self.act_finger_3d.blockSignals(False) + # 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()) self._apply_arrow_rotation_for_sensor() @@ -966,139 +988,69 @@ class StudioMainWindow(QMainWindow): ``recompute`` only while a pass is actually running: the label is a claim about the pixels on screen, and a stale claim is worse than none. """ - if self._view_preset == "replay": + if self._in_replay_view(): return "recompute" if self._recompute_running else "replay" return "live" - def _shows_3d(self) -> bool: - """Whether the 3D pane belongs to the current view and sensor. + # -------------------------------------------------------- layout (forwards) + # The panel arrangement, the active view and the 3D pane's switch live in + # ``ViewLayoutController``. These forward for the call sites still in this file and + # for the tests / ``tools/ui_check``, which drive the window; they are deleted as + # the call sites move to ``self.layout_ctl.*``. + @property + def _view_preset(self) -> str: + return self.layout_ctl.view_preset - One predicate for both the pane's visibility and the splitter layout: the - layout has to know the same thing the visibility does, or the pane ends up - visible and standing at its minimum width. - """ - return ( - self._view_preset in VIEW_SHOWS_3D - and self.sensor_picker.sensor_type() == "finger" - and bool(self._show_finger_3d) - ) + @_view_preset.setter + def _view_preset(self, preset: str) -> None: + self.layout_ctl.set_view_preset(preset) - def _splitter_sizes_for(self, preset: str) -> list: - """The preset's layout, with room for the 3D pane when it is on.""" - sizes = list(VIEW_SPLITTER_SIZES.get(preset, DEFAULT_SPLITTER_SIZES)) - if self._shows_3d() and sizes and sizes[0] == 0: - width = DEFAULT_3D_PANE_WIDTH - widest = max(range(1, len(sizes)), key=lambda index: sizes[index]) - take = min(width, max(sizes[widest] - MIN_PANEL_WIDTHS["plots"], 0)) - sizes[0] = take - sizes[widest] = sizes[widest] - take - return sizes + @property + def _show_cal(self) -> bool: + return self.layout_ctl.show_cal - def _apply_panel_visibility(self) -> None: - """One place decides which panels the current view shows. + @_show_cal.setter + def _show_cal(self, visible: bool) -> None: + self.layout_ctl.show_cal = visible - The view preset owns the 2D panel and the calibration panel; the 3D - panel additionally needs a finger sensor and its own toggle. ``_show_cal`` - is kept in step so the View menu, the preset and the panels cannot - disagree. - """ - is_finger = self.sensor_picker.sensor_type() == "finger" - preset = self._view_preset - show_3d = self._shows_3d() - show_arrow = preset != "calibration" - show_cal = self._show_cal if preset == "custom" else preset == "calibration" - self._show_cal = show_cal - if show_3d: - # Built on first use only: until the pane is actually shown, nothing - # imports GL and no scene is read from disk. - self.mesh3d_panel.ensure_view() - self.mesh3d_panel.set_enabled(show_3d) - self.mesh3d_panel.setVisible(show_3d) - # Where the field comes from: a live view acquires it, a replay has none - # stored and only a recompute pass produces one. - self.mesh3d_panel.set_source_kind(self._current_source_kind()) - self.arrow_panel.setVisible(show_arrow) - self.force_plots.setVisible(True) - self.cal_panel.setVisible(show_cal) - self.mesh3d_panel.setMinimumWidth(MIN_PANEL_WIDTHS["mesh3d"]) - self.arrow_panel.setMinimumWidth(MIN_PANEL_WIDTHS["arrow"]) - self.force_plots.setMinimumWidth(MIN_PANEL_WIDTHS["plots"]) - self.cal_panel.setMinimumWidth(MIN_PANEL_WIDTHS["cal"]) - self.act_finger_3d.setEnabled(is_finger) - self.act_finger_3d.blockSignals(True) - self.act_finger_3d.setChecked(show_3d) - self.act_finger_3d.blockSignals(False) - self.act_cal_panel.blockSignals(True) - self.act_cal_panel.setChecked(show_cal) - self.act_cal_panel.blockSignals(False) - # Panel weights: the charts are the widest panel in every view, and the - # 2D panel is deliberately the smaller one. - arrow_weight = 3 - plots_weight = 4 - for i in range(self.content_splitter.count()): - widget = self.content_splitter.widget(i) - if widget is self.mesh3d_panel: - self.content_splitter.setStretchFactor(i, 2 if show_3d else 0) - elif widget is self.arrow_panel: - self.content_splitter.setStretchFactor(i, arrow_weight if show_arrow else 0) - elif widget is self.force_plots: - self.content_splitter.setStretchFactor(i, plots_weight) - elif widget is self.cal_panel: - self.content_splitter.setStretchFactor(i, 3 if show_cal else 0) + @property + def _show_finger_3d(self) -> bool: + return self.layout_ctl.show_finger_3d + + @_show_finger_3d.setter + def _show_finger_3d(self, enabled: bool) -> None: + self.layout_ctl.show_finger_3d = enabled - # ------------------------------------------------------------ view presets def _apply_view_preset( self, preset: str, *, apply_sizes: bool = True, persist: bool = True ) -> None: - """Show one working arrangement of the panels. + self.layout_ctl.apply_preset( + preset, apply_sizes=apply_sizes, persist=persist + ) - The three top-level views are layouts of the *same* window: switching - between live measurement, history browsing and calibration must not - require opening a dialog, and each view gets the space its own content - needs (calibration especially: the six-column table and its action row - need width, not a 300 px column). - """ - preset = preset if preset in VIEW_PRESETS else "custom" - self._view_preset = preset - self.prefs["view_preset"] = preset - if preset == "calibration": - self._show_cal = True - elif preset in ("live", "replay"): - self._show_cal = False - # The rail shows the source this view needs, and only that source: a - # camera while measuring, a recorded video while replaying. It is the one - # visible difference between the two views. - self.replay_ctl.pause_playback() - self.toolbar.set_source_view("replay" if preset == "replay" else "live") - # The rail's primary button is this view's main action: acquire while - # measuring, play back while replaying; and the second button is that view's - # own reset — or, in the replay view, an unconditional stop. - self.toolbar.set_run_role("playback" if preset == "replay" else "acquire") - self.toolbar.set_reset_role("stop" if preset == "replay" else "reset") - self._apply_panel_visibility() - if apply_sizes and preset in VIEW_SPLITTER_SIZES: - self.content_splitter.setSizes(self._splitter_sizes_for(preset)) - if preset == "replay": - # History browsing is the point of this view: the charts read the - # open recording, and the chart cursor is its timeline. Without a - # recording the charts stay on the live session and follow it. - self.replay_ctl.apply_source(True) - self.replay_ctl.refresh_state() - elif preset == "live": - self.replay_ctl.apply_source(False) - # Leaving the replay must not leave its video on the rail: the live - # view is a camera, and the summary says so (see ``_refresh_status_rail``). - self.replay_ctl.on_recompute_toggled(False) - self.brand_bar.set_active_view(preset if preset != "custom" else None) - self._sync_menu_checks() - self.replay_ctl.sync_actions() - self._update_run_enabled() - self._refresh_status_rail() - if persist: - self._persist_prefs() + def _apply_panel_visibility(self) -> None: + self.layout_ctl.apply_panel_visibility() + + def _shows_3d(self) -> bool: + return self.layout_ctl.shows_3d() + + def _splitter_sizes_for(self, preset: str) -> list: + return self.layout_ctl.splitter_sizes_for(preset) + + def _set_settings_expanded(self, expanded: bool, *, persist: bool = True) -> None: + self.layout_ctl.set_settings_expanded(expanded, persist=persist) + + def _sync_settings_action(self) -> None: + self.layout_ctl.sync_settings_action() + + def _sync_3d_source(self) -> None: + self.layout_ctl.sync_3d_source() + + def _restore_3d_settings(self) -> None: + self.layout_ctl.restore_3d_settings() def view_preset(self) -> str: - return self._view_preset + return self.layout_ctl.view_preset def _update_run_enabled(self) -> None: if self.toolbar.run_role() == "playback": @@ -1355,40 +1307,7 @@ class StudioMainWindow(QMainWindow): directory=directory, ) - # -------------------------------------------------------------- settings - def _sync_settings_action(self) -> None: - action = getattr(self, "act_settings", None) - if action is None: - return - expanded = self.toolbar.settings_expanded() - action.blockSignals(True) - action.setChecked(expanded) - action.blockSignals(False) - - def _set_settings_expanded(self, expanded: bool, *, persist: bool = True) -> None: - self.toolbar.set_settings_expanded(bool(expanded)) - self.prefs["settings_expanded"] = bool(expanded) - self._sync_settings_action() - if persist: - self._persist_prefs() - - def _on_settings_toggled(self, expanded: bool) -> None: - self._set_settings_expanded(expanded) - - def _on_settings_action_toggled(self, expanded: bool) -> None: - self._set_settings_expanded(expanded) - # ------------------------------------------------------------------ nav - def _on_live_requested(self) -> None: - self._apply_view_preset("live") - - def _on_replay_requested(self) -> None: - self._apply_view_preset("replay") - - def _on_calibration_requested(self) -> None: - self._apply_view_preset("calibration") - - def _set_theme(self, theme: str) -> None: self._theme = theme self.prefs["theme"] = theme @@ -1552,24 +1471,6 @@ class StudioMainWindow(QMainWindow): ) self.force_plots.set_fixed_newton(self.toolbar.units() == "newton") - def _on_cal_panel_toggled(self, visible: bool) -> None: - """The View-menu toggle *is* the calibration view switch. - - Turning the panel on enters the calibration view (so it gets the width - its table needs); turning it off returns to the live view rather than - leaving a hidden panel behind a highlighted nav item. - """ - if visible: - self._apply_view_preset("calibration") - return - self._view_preset = "live" - self._show_cal = False - self.prefs["view_preset"] = "live" - self.prefs["show_cal_panel"] = False - self._apply_panel_visibility() - self.brand_bar.set_active_view("live") - self._persist_prefs() - def _on_calibration_exported(self, path: str) -> None: """Note the coefficients that were written. Nothing is applied here. @@ -1723,51 +1624,6 @@ class StudioMainWindow(QMainWindow): def _on_calibration_cancelled(self) -> None: self.cal_panel.on_record_cancelled() - def _on_finger_3d_toggled(self, visible: bool) -> None: - if self.sensor_picker.sensor_type() != "finger": - return - self._show_finger_3d = bool(visible) - self.prefs["show_finger_3d"] = self._show_finger_3d - self._apply_panel_visibility() - self._persist_prefs() - - # ------------------------------------------------------------------- 3D pane - def _on_3d_pane_toggled(self, enabled: bool) -> None: - """The pane's own switch. Same state as the View menu entry, one owner. - - The early return is what keeps the two controls from driving each other: the - window synchronises the pane after every visibility change, and this handler - is the way back. - """ - if bool(enabled) == bool(self._show_finger_3d): - return - self._show_finger_3d = bool(enabled) - self.prefs["show_finger_3d"] = self._show_finger_3d - self._apply_panel_visibility() - self._persist_prefs() - - def _sync_3d_source(self) -> None: - """Tell the 3D pane which sensor it is looking at. No IO, no scene read. - - The device's own geometry description travels with it as a *provider*: deriving - it reads and digests the unroll map the runtime loads, and the scene loader does - that on its worker thread. Without it the pane could only compare the asset with - itself, which is how a scene built on a different mapping would still look - matched. - - The release keys travel the same way (from the preferences, paths only): a signed - or sealed package is refused by the container without them, which left a - legitimate protected release unopenable from the application. - """ - from orisys_studio.scene3d.keys import resolve_scene_keys - - self.mesh3d_panel.set_keys(resolve_scene_keys(self.prefs)) - self.mesh3d_panel.set_sensor( - self.sensor_picker.sensor_type(), - expected_provider=self._device_geometry_provider(), - package_override=self.prefs.get("scene3d_package"), - ) - def _device_geometry_provider(self): """A callable the loader runs off the GUI thread: what this device really is. @@ -1786,14 +1642,6 @@ class StudioMainWindow(QMainWindow): return describe - def _restore_3d_settings(self) -> None: - """Persisted display settings, repaired by ``scene3d.prefs`` on the way in.""" - from orisys_studio.scene3d.prefs import settings_from_prefs - - self.mesh3d_panel.set_display_settings( - settings_from_prefs(self.prefs.get("scene3d_display")) - ) - def _on_packet_for_3d(self, packet) -> None: """Runs on the **acquisition thread**. @@ -1823,8 +1671,8 @@ class StudioMainWindow(QMainWindow): ) # The pane follows the sensor: a box sensor has no 3D scene, and a finger's # missing package must not be reported while looking at a box. - self._sync_3d_source() - self._apply_panel_visibility() + self.layout_ctl.sync_3d_source() + self.layout_ctl.apply_panel_visibility() self._apply_arrow_rotation_for_sensor() self._update_run_enabled() # The rail's sensor summary and the sensor dialog both state the product diff --git a/apps/studio/src/orisys_studio/view_layout.py b/apps/studio/src/orisys_studio/view_layout.py new file mode 100644 index 0000000..c26daeb --- /dev/null +++ b/apps/studio/src/orisys_studio/view_layout.py @@ -0,0 +1,398 @@ +"""Which panels the window shows, in what arrangement, and which view is active. + +The three top-level views (live measurement, history browsing, calibration) are layouts +of the *same* window, so what changes between them is exactly this: panel visibility, +the splitter's sizes and stretch factors, the status of the view menu's checkable +entries, and the 3D pane's presence for a finger sensor. That is one change driver — +"the furniture" — and it used to be scattered through ``app_window.py`` next to the +acquisition session and the replay policy. + +What the controller owns: the active preset (``view_preset``), whether the calibration +panel is shown (``show_cal``) and whether the 3D pane is on (``show_finger_3d``). Its +initial values come from the preferences, so the window does not keep a second copy. + +What it borrows from the window is named by :class:`LayoutHost` — persisting the choice, +the run button's state, the rail, the arrow rotation that follows the sensor, and the +source kind the 3D pane should claim. It drives the panels and the focus of the session's +view directly, and it tells the replay controller that the view changed, because leaving +the replay view is what hands the charts back to the live session. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Any, Optional, Protocol + +from PyQt5.QtCore import QObject + +logger = logging.getLogger(__name__) + + +class LayoutHost(Protocol): + """What the layout controller needs from the window. + + The names are the window's own (its transitional shims included), because the + window is the only implementation today. + """ + + prefs: dict + + def _persist_prefs(self) -> None: ... + + def _update_run_enabled(self) -> None: ... + + def _refresh_status_rail(self) -> None: ... + + def _current_source_kind(self) -> str: ... + + def _device_geometry_provider(self): ... + + +@dataclass(frozen=True) +class LayoutActions: + """The checkable view-menu entries whose state the layout owns.""" + + cal_panel: Any + finger_3d: Any + settings: Any + + +class ViewLayoutController(QObject): + """Panel arrangement, panel visibility, and the active view.""" + + def __init__( + self, + *, + host: LayoutHost, + splitter: Any, + mesh_panel: Any, + image_panel: Any, + charts: Any, + cal_panel: Any, + sensor_picker: Any, + brand_bar: Any, + toolbar: Any, + replay: Any, + presets: tuple[str, ...], + shows_3d_in: tuple[str, ...], + splitter_sizes: dict, + default_splitter_sizes: tuple, + min_panel_widths: dict, + default_3d_pane_width: int, + parent: Optional[QObject] = None, + ) -> None: + super().__init__(parent) + self._host = host + self._splitter = splitter + self._mesh_panel = mesh_panel + self._image_panel = image_panel + self._charts = charts + self._cal_panel = cal_panel + self._picker = sensor_picker + self._brand_bar = brand_bar + self._toolbar = toolbar + self._replay = replay + self._presets = tuple(presets) + self._shows_3d_in = tuple(shows_3d_in) + self._splitter_sizes = dict(splitter_sizes) + self._default_splitter_sizes = tuple(default_splitter_sizes) + self._min_panel_widths = dict(min_panel_widths) + self._default_3d_pane_width = int(default_3d_pane_width) + preset = str(host.prefs.get("view_preset") or "custom") + self._view_preset = preset if preset in self._presets else "custom" + self._show_cal = bool(host.prefs.get("show_cal_panel", False)) + self._show_finger_3d = bool(host.prefs.get("show_finger_3d", True)) + self._actions: Optional[LayoutActions] = None + + # ------------------------------------------------------------------ wiring + def bind_actions( + self, *, cal_panel: Any, finger_3d: Any, settings: Any + ) -> None: + """Take the view-menu entries once the menus exist, and connect them.""" + self._actions = LayoutActions( + cal_panel=cal_panel, finger_3d=finger_3d, settings=settings + ) + cal_panel.toggled.connect(self.on_cal_panel_toggled) + finger_3d.toggled.connect(self.on_finger_3d_toggled) + settings.toggled.connect(self.on_settings_action_toggled) + + # ------------------------------------------------------------------- state + @property + def view_preset(self) -> str: + return self._view_preset + + def set_view_preset(self, preset: str) -> str: + """Adopt a preset without re-arranging anything (see ``apply_preset``). + + Used by the callers that only want to *state* which view is active — a + restored preference, or a preset that failed to apply. + """ + self._view_preset = preset if preset in self._presets else "custom" + return self._view_preset + + @property + def show_cal(self) -> bool: + return self._show_cal + + @show_cal.setter + def show_cal(self, visible: bool) -> None: + self._show_cal = bool(visible) + + @property + def show_finger_3d(self) -> bool: + return self._show_finger_3d + + @show_finger_3d.setter + def show_finger_3d(self, enabled: bool) -> None: + self._show_finger_3d = bool(enabled) + + def in_replay_view(self) -> bool: + return self._view_preset == "replay" + + def shows_3d(self) -> bool: + """Whether the 3D pane belongs to the current view and sensor. + + One predicate for both the pane's visibility and the splitter layout: the + layout has to know the same thing the visibility does, or the pane ends up + visible and standing at its minimum width. + """ + return ( + self._view_preset in self._shows_3d_in + and self._picker.sensor_type() == "finger" + and bool(self._show_finger_3d) + ) + + def splitter_sizes_for(self, preset: str) -> list: + """The preset's layout, with room for the 3D pane when it is on.""" + sizes = list(self._splitter_sizes.get(preset, self._default_splitter_sizes)) + if self.shows_3d() and sizes and sizes[0] == 0: + width = self._default_3d_pane_width + widest = max(range(1, len(sizes)), key=lambda index: sizes[index]) + take = min( + width, max(sizes[widest] - self._min_panel_widths["plots"], 0) + ) + sizes[0] = take + sizes[widest] = sizes[widest] - take + return sizes + + # -------------------------------------------------------------- visibility + def apply_panel_visibility(self) -> None: + """One place decides which panels the current view shows. + + The view preset owns the 2D panel and the calibration panel; the 3D panel + additionally needs a finger sensor and its own toggle. ``show_cal`` is kept in + step so the View menu, the preset and the panels cannot disagree. + """ + is_finger = self._picker.sensor_type() == "finger" + preset = self._view_preset + show_3d = self.shows_3d() + show_arrow = preset != "calibration" + show_cal = self._show_cal if preset == "custom" else preset == "calibration" + self._show_cal = show_cal + if show_3d: + # Built on first use only: until the pane is actually shown, nothing + # imports GL and no scene is read from disk. + self._mesh_panel.ensure_view() + self._mesh_panel.set_enabled(show_3d) + self._mesh_panel.setVisible(show_3d) + # Where the field comes from: a live view acquires it, a replay has none + # stored and only a recompute pass produces one. + self._mesh_panel.set_source_kind(self._host._current_source_kind()) + self._image_panel.setVisible(show_arrow) + self._charts.setVisible(True) + self._cal_panel.setVisible(show_cal) + self._mesh_panel.setMinimumWidth(self._min_panel_widths["mesh3d"]) + self._image_panel.setMinimumWidth(self._min_panel_widths["arrow"]) + self._charts.setMinimumWidth(self._min_panel_widths["plots"]) + self._cal_panel.setMinimumWidth(self._min_panel_widths["cal"]) + self._set_check(self._actions.finger_3d if self._actions else None, show_3d, enabled=is_finger) + self._set_check(self._actions.cal_panel if self._actions else None, show_cal) + # Panel weights: the charts are the widest panel in every view, and the 2D + # panel is deliberately the smaller one. + arrow_weight = 3 + plots_weight = 4 + for i in range(self._splitter.count()): + widget = self._splitter.widget(i) + if widget is self._mesh_panel: + self._splitter.setStretchFactor(i, 2 if show_3d else 0) + elif widget is self._image_panel: + self._splitter.setStretchFactor(i, arrow_weight if show_arrow else 0) + elif widget is self._charts: + self._splitter.setStretchFactor(i, plots_weight) + elif widget is self._cal_panel: + self._splitter.setStretchFactor(i, 3 if show_cal else 0) + + def sync_view_checks(self) -> None: + """The view menu's checkable entries follow the state they describe.""" + if self._actions is None: + return + is_finger = self._picker.sensor_type() == "finger" + self._actions.finger_3d.setEnabled(is_finger) + self._set_check( + self._actions.finger_3d, + bool(self._show_finger_3d) if is_finger else False, + ) + self._set_check(self._actions.cal_panel, self._show_cal) + + def sync_settings_action(self) -> None: + """The settings entry follows the drawer the toolbar is showing.""" + if self._actions is None: + return + self._set_check(self._actions.settings, self._toolbar.settings_expanded()) + + @staticmethod + def _set_check(action: Any, checked: bool, *, enabled: Optional[bool] = None) -> None: + """Set a checkable entry without letting it drive its own handler.""" + if action is None: + return + if enabled is not None: + action.setEnabled(bool(enabled)) + action.blockSignals(True) + action.setChecked(bool(checked)) + action.blockSignals(False) + + # ------------------------------------------------------------------ presets + def apply_preset( + self, preset: str, *, apply_sizes: bool = True, persist: bool = True + ) -> None: + """Show one working arrangement of the panels. + + The three top-level views are layouts of the *same* window: switching between + live measurement, history browsing and calibration must not require opening a + dialog, and each view gets the space its own content needs (calibration + especially: the six-column table and its action row need width, not a 300 px + column). + """ + preset = preset if preset in self._presets else "custom" + self._view_preset = preset + self._host.prefs["view_preset"] = preset + if preset == "calibration": + self._show_cal = True + elif preset in ("live", "replay"): + self._show_cal = False + # The rail shows the source this view needs, and only that source: a camera + # while measuring, a recorded video while replaying. It is the one visible + # difference between the two views. + self._replay.pause_playback() + self._toolbar.set_source_view("replay" if preset == "replay" else "live") + # The rail's primary button is this view's main action: acquire while + # measuring, play back while replaying; and the second button is that view's + # own reset — or, in the replay view, an unconditional stop. + self._toolbar.set_run_role("playback" if preset == "replay" else "acquire") + self._toolbar.set_reset_role("stop" if preset == "replay" else "reset") + self.apply_panel_visibility() + if apply_sizes and preset in self._splitter_sizes: + self._splitter.setSizes(self.splitter_sizes_for(preset)) + if preset == "replay": + # History browsing is the point of this view: the charts read the open + # recording, and the chart cursor is its timeline. Without a recording the + # charts stay on the live session and follow it. + self._replay.apply_source(True) + self._replay.refresh_state() + elif preset == "live": + self._replay.apply_source(False) + # Leaving the replay must not leave its video on the rail: the live view + # is a camera, and the summary says so (see the window's status rail). + self._replay.on_recompute_toggled(False) + self._brand_bar.set_active_view(preset if preset != "custom" else None) + self.sync_view_checks() + self._replay.sync_actions() + self._host._update_run_enabled() + self._host._refresh_status_rail() + if persist: + self._host._persist_prefs() + + def on_live_requested(self) -> None: + self.apply_preset("live") + + def on_replay_requested(self) -> None: + self.apply_preset("replay") + + def on_calibration_requested(self) -> None: + self.apply_preset("calibration") + + # ------------------------------------------------------------------ toggles + def on_cal_panel_toggled(self, visible: bool) -> None: + """The View-menu toggle *is* the calibration view switch. + + Turning the panel on enters the calibration view (so it gets the width its + table needs); turning it off returns to the live view rather than leaving a + hidden panel behind a highlighted nav item. + """ + if visible: + self.apply_preset("calibration") + return + self._view_preset = "live" + self._show_cal = False + self._host.prefs["view_preset"] = "live" + self._host.prefs["show_cal_panel"] = False + self.apply_panel_visibility() + self._brand_bar.set_active_view("live") + self._host._persist_prefs() + + def on_finger_3d_toggled(self, visible: bool) -> None: + if self._picker.sensor_type() != "finger": + return + self._show_finger_3d = bool(visible) + self._host.prefs["show_finger_3d"] = self._show_finger_3d + self.apply_panel_visibility() + self._host._persist_prefs() + + def on_3d_pane_toggled(self, enabled: bool) -> None: + """The pane's own switch. Same state as the View menu entry, one owner. + + The early return is what keeps the two controls from driving each other: this + controller synchronises the pane after every visibility change, and this + handler is the way back. + """ + if bool(enabled) == bool(self._show_finger_3d): + return + self._show_finger_3d = bool(enabled) + self._host.prefs["show_finger_3d"] = self._show_finger_3d + self.apply_panel_visibility() + self._host._persist_prefs() + + def set_settings_expanded(self, expanded: bool, *, persist: bool = True) -> None: + self._toolbar.set_settings_expanded(bool(expanded)) + self._host.prefs["settings_expanded"] = bool(expanded) + self.sync_settings_action() + if persist: + self._host._persist_prefs() + + def on_settings_toggled(self, expanded: bool) -> None: + self.set_settings_expanded(expanded) + + def on_settings_action_toggled(self, expanded: bool) -> None: + self.set_settings_expanded(expanded) + + # ------------------------------------------------------------------- 3D pane + def sync_3d_source(self) -> None: + """Tell the 3D pane which sensor it is looking at. No IO, no scene read. + + The device's own geometry description travels with it as a *provider*: deriving + it reads and digests the unroll map the runtime loads, and the scene loader does + that on its worker thread. Without it the pane could only compare the asset with + itself, which is how a scene built on a different mapping would still look + matched. + + The release keys travel the same way (from the preferences, paths only): a signed + or sealed package is refused by the container without them, which left a + legitimate protected release unopenable from the application. + """ + from orisys_studio.scene3d.keys import resolve_scene_keys + + self._mesh_panel.set_keys(resolve_scene_keys(self._host.prefs)) + self._mesh_panel.set_sensor( + self._picker.sensor_type(), + expected_provider=self._host._device_geometry_provider(), + package_override=self._host.prefs.get("scene3d_package"), + ) + + def restore_3d_settings(self) -> None: + """Persisted display settings, repaired by ``scene3d.prefs`` on the way in.""" + from orisys_studio.scene3d.prefs import settings_from_prefs + + self._mesh_panel.set_display_settings( + settings_from_prefs(self._host.prefs.get("scene3d_display")) + ) diff --git a/apps/studio/tests/test_view_layout.py b/apps/studio/tests/test_view_layout.py new file mode 100644 index 0000000..472a3d4 --- /dev/null +++ b/apps/studio/tests/test_view_layout.py @@ -0,0 +1,370 @@ +"""Acceptance for the panel layout, on its own. + +``ViewLayoutController`` owns what a *view* is: which panels are on screen, in what +arrangement, and which preset is active. It drives the panels through their own small +interfaces and borrows a handful of window-level operations through ``LayoutHost``, so +both can be stood in for — which is the point of the split, and what these tests use. +""" + +from __future__ import annotations + +import pytest + +from orisys_studio.view_layout import ViewLayoutController + +VIEW_PRESETS = ("custom", "live", "replay", "calibration") +VIEW_SHOWS_3D = ("custom", "live", "replay") +VIEW_SPLITTER_SIZES = {"live": (0, 360, 1080, 0), "replay": (0, 360, 1080, 0), "calibration": (0, 0, 560, 880)} +DEFAULT_SPLITTER_SIZES = (300, 360, 680, 0) +MIN_PANEL_WIDTHS = {"mesh3d": 260, "arrow": 300, "plots": 340, "cal": 300} + + +class _Action: + def __init__(self, checked: bool = False) -> None: + self.checked = bool(checked) + self.enabled: bool | None = None + self.blocked = 0 + self.toggled = _Signal() + + def isChecked(self) -> bool: # noqa: N802 + return self.checked + + def setChecked(self, value: bool) -> None: # noqa: N802 + self.checked = bool(value) + + def setEnabled(self, value: bool) -> None: # noqa: N802 + self.enabled = bool(value) + + def blockSignals(self, flag: bool) -> None: # noqa: N802 + self.blocked += 1 + + +class _Signal: + def __init__(self) -> None: + self.slots: list = [] + + def connect(self, slot) -> None: + self.slots.append(slot) + + +class _Panel: + """A panel as the layout treats it: visibility, a minimum width, a stretch.""" + + def __init__(self) -> None: + self.visible: bool | None = None + self.minimum_width: int | None = None + self.views = 0 + + def setVisible(self, visible: bool) -> None: # noqa: N802 + self.visible = bool(visible) + + def setMinimumWidth(self, width: int) -> None: # noqa: N802 + self.minimum_width = int(width) + + def ensure_view(self): + self.views += 1 + + def set_enabled(self, enabled: bool) -> None: + self.enabled = bool(enabled) + + def set_source_kind(self, kind: str) -> None: + self.kind = str(kind) + + +class _Splitter: + def __init__(self, widgets) -> None: + self._widgets = list(widgets) + self.stretches: list[tuple[int, int]] = [] + self.sizes: list = [] + + def count(self) -> int: + return len(self._widgets) + + def widget(self, index: int): + return self._widgets[index] + + def setStretchFactor(self, index: int, value: int) -> None: # noqa: N802 + self.stretches.append((index, value)) + + def setSizes(self, sizes) -> None: # noqa: N802 + self.sizes = list(sizes) + + +class _Picker: + def __init__(self, sensor_type: str = "box") -> None: + self._sensor = sensor_type + + def sensor_type(self) -> str: + return self._sensor + + +class _Toolbar: + def __init__(self, *, expanded: bool = False) -> None: + self._expanded = expanded + self.source_views: list[str] = [] + self.run_roles: list[str] = [] + self.reset_roles: list[str] = [] + + def set_source_view(self, view: str) -> None: + self.source_views.append(view) + + def set_run_role(self, role: str) -> None: + self.run_roles.append(role) + + def set_reset_role(self, role: str) -> None: + self.reset_roles.append(role) + + def settings_expanded(self) -> bool: + return self._expanded + + def set_settings_expanded(self, expanded: bool) -> None: + self._expanded = bool(expanded) + + +class _BrandBar: + def __init__(self) -> None: + self.active: list = [] + + def set_active_view(self, preset) -> None: + self.active.append(preset) + + +class _Replay: + def __init__(self) -> None: + self.paused = 0 + self.sources: list[bool] = [] + self.recomputed: list[bool] = [] + self.refreshed = 0 + self.synced = 0 + + def pause_playback(self) -> None: + self.paused += 1 + + def apply_source(self, active: bool) -> None: + self.sources.append(bool(active)) + + def on_recompute_toggled(self, checked: bool) -> None: + self.recomputed.append(bool(checked)) + + def refresh_state(self) -> None: + self.refreshed += 1 + + def sync_actions(self) -> None: + self.synced += 1 + + +class _Host: + def __init__(self, prefs: dict) -> None: + self.prefs = dict(prefs) + self.persisted = 0 + self.run_updates = 0 + self.rail_updates = 0 + self.source_kind = "live" + + def _persist_prefs(self) -> None: + self.persisted += 1 + + def _update_run_enabled(self) -> None: + self.run_updates += 1 + + def _refresh_status_rail(self) -> None: + self.rail_updates += 1 + + def _current_source_kind(self) -> str: + return self.source_kind + + def _device_geometry_provider(self): + return None + + +def _controller(*, prefs=None, sensor_type="box", settings_expanded=False): + prefs = dict(prefs or {}) + host = _Host(prefs) + mesh, image, charts, cal = _Panel(), _Panel(), _Panel(), _Panel() + splitter = _Splitter([mesh, image, charts, cal]) + toolbar = _Toolbar(expanded=settings_expanded) + brand = _BrandBar() + replay = _Replay() + controller = ViewLayoutController( + host=host, + splitter=splitter, + mesh_panel=mesh, + image_panel=image, + charts=charts, + cal_panel=cal, + sensor_picker=_Picker(sensor_type), + brand_bar=brand, + toolbar=toolbar, + replay=replay, + presets=VIEW_PRESETS, + shows_3d_in=VIEW_SHOWS_3D, + splitter_sizes=VIEW_SPLITTER_SIZES, + default_splitter_sizes=DEFAULT_SPLITTER_SIZES, + min_panel_widths=MIN_PANEL_WIDTHS, + default_3d_pane_width=340, + ) + actions = { + "cal_panel": _Action(), + "finger_3d": _Action(), + "settings": _Action(), + } + controller.bind_actions(**actions) + parts = { + "host": host, + "mesh": mesh, + "image": image, + "charts": charts, + "cal": cal, + "splitter": splitter, + "toolbar": toolbar, + "brand": brand, + "replay": replay, + "actions": actions, + } + return controller, parts + + +# --------------------------------------------------------------------- tests + + +def test_a_preset_decides_which_panels_a_view_shows(): + controller, parts = _controller(prefs={"view_preset": "live"}) + + controller.apply_preset("calibration") + assert controller.view_preset == "calibration" + assert parts["cal"].visible is True + assert parts["image"].visible is False, "the 2D panel does not belong to calibration" + assert parts["charts"].visible is True + assert parts["host"].prefs["view_preset"] == "calibration" + assert parts["toolbar"].source_views[-1] == "live" + assert parts["toolbar"].reset_roles[-1] == "reset" + assert parts["host"].persisted == 1 + + controller.apply_preset("replay") + assert parts["image"].visible is True + assert parts["toolbar"].source_views[-1] == "replay" + assert parts["toolbar"].run_roles[-1] == "playback" + assert parts["toolbar"].reset_roles[-1] == "stop" + # The charts follow the recording in this view, and the replay's own entries are + # re-synced with it. (Calibration did neither of those: it has no replay source.) + assert parts["replay"].sources == [True] + assert parts["replay"].refreshed == 1 + assert parts["replay"].synced == 2 + assert parts["brand"].active[-1] == "replay" + + +def test_an_unknown_preset_is_a_custom_layout_not_a_crash(): + controller, parts = _controller() + controller.apply_preset("nonsense") + assert controller.view_preset == "custom" + assert parts["host"].prefs["view_preset"] == "custom" + assert parts["brand"].active[-1] is None + + +def test_leaving_the_replay_view_stops_the_transport_and_drops_the_pass_label(): + controller, parts = _controller(prefs={"view_preset": "replay"}) + controller.apply_preset("live") + assert parts["replay"].paused == 1, "the transport kept running across a view switch" + assert parts["replay"].sources[-1] is False, "the charts still read the recording" + assert parts["replay"].recomputed == [False], "the pass label survived the view" + + +def test_the_3d_pane_needs_a_finger_sensor_and_its_own_switch(): + controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="box") + assert not controller.shows_3d() + + # A finger sensor alone is not enough either: the switch has to be on. + controller._picker = _Picker("finger") + controller.show_finger_3d = False + assert not controller.shows_3d() + controller.show_finger_3d = True + assert controller.shows_3d() + + # ...and the calibration view never shows it. + controller.apply_preset("calibration") + assert not controller.shows_3d() + assert parts["mesh"].visible is False + assert parts["mesh"].views == 0, "a hidden pane must not build a GL view" + + +def test_a_preset_that_gives_no_width_to_the_3d_pane_takes_it_from_the_widest(): + controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="finger") + controller.show_finger_3d = True + + sizes = controller.splitter_sizes_for("live") + assert sizes[0] == 340, sizes + assert sum(sizes) == sum(VIEW_SPLITTER_SIZES["live"]), "the layout changed width" + # Without the pane the table is used as it stands. + controller.show_finger_3d = False + assert controller.splitter_sizes_for("live") == list(VIEW_SPLITTER_SIZES["live"]) + + +def test_the_calibration_panel_toggle_is_the_calibration_view_switch(): + controller, parts = _controller(prefs={"view_preset": "live"}) + + controller.on_cal_panel_toggled(True) + assert controller.view_preset == "calibration" + assert controller.show_cal is True + + controller.on_cal_panel_toggled(False) + assert controller.view_preset == "live" + assert controller.show_cal is False + assert parts["host"].prefs["show_cal_panel"] is False + assert parts["brand"].active[-1] == "live" + assert controller.show_cal is False + + +def test_the_3d_pane_switch_does_not_drive_itself(): + controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="finger") + controller.show_finger_3d = True + before = len(parts["mesh"].__dict__) + + # The pane is already on: the toggle says so again and nothing is written. + controller.on_3d_pane_toggled(True) + assert "show_finger_3d" not in parts["host"].prefs, ( + "a no-op toggle still persisted a preference" + ) + + controller.on_3d_pane_toggled(False) + assert controller.show_finger_3d is False + assert parts["host"].prefs["show_finger_3d"] is False + assert parts["mesh"].visible is False + assert len(parts["mesh"].__dict__) >= before # sanity: the panel was reused + + +def test_the_view_menu_entries_follow_what_is_on_screen(): + controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="box") + controller.sync_view_checks() + assert parts["actions"]["finger_3d"].enabled is False, "a box sensor has no scene" + assert parts["actions"]["finger_3d"].checked is False + assert parts["actions"]["cal_panel"].checked is False + + controller.on_cal_panel_toggled(True) + assert parts["actions"]["cal_panel"].checked is True + assert parts["actions"]["cal_panel"].blocked > 0, ( + "the check was set without blocking its own signal" + ) + + +def test_the_settings_drawer_keeps_the_menu_entry_in_step(): + controller, parts = _controller(prefs={"view_preset": "live"}) + controller.set_settings_expanded(True) + assert parts["toolbar"].settings_expanded() is True + assert parts["actions"]["settings"].checked is True + assert parts["host"].prefs["settings_expanded"] is True + assert parts["host"].persisted == 1 + + controller.set_settings_expanded(False, persist=False) + assert parts["actions"]["settings"].checked is False + assert parts["host"].persisted == 1, "persist=False still wrote the preferences" + + +def test_the_3d_pane_gets_the_keys_and_the_sensor_it_should_read(): + controller, parts = _controller(prefs={"view_preset": "live"}, sensor_type="finger") + seen = {} + parts["mesh"].set_keys = lambda keys: seen.setdefault("keys", keys) + parts["mesh"].set_sensor = lambda *a, **k: seen.setdefault("sensor", (a, k)) + + controller.sync_3d_source() + assert seen["sensor"][0] == ("finger",), seen["sensor"] + assert "keys" in seen