拆分 C1:面板布局与视图预设移入 ViewLayoutController(app_window 2499→2347 行)

- What: 新增 apps/studio/src/orisys_studio/view_layout.py:把「一个视图是什么」从
  app_window 搬进 ViewLayoutController——面板可见性与最小宽度、splitter 尺寸与 stretch、
  三个预设(live / replay / calibration)的切换、视图菜单三个勾选项的状态、3D 面板的
  开与关(含它看哪个传感器、恢复持久化的显示设置)、设置抽屉的展开状态。
  控制器自己拥有 view_preset / show_cal / show_finger_3d 三个状态(初值直接取自
  preferences,窗口不再留第二份),并直接驱动面板;`LayoutHost` Protocol 写明它向窗口
  借的四件事:_persist_prefs / _update_run_enabled / _refresh_status_rail /
  _current_source_kind(以及 3D 的 _device_geometry_provider)。
  预设切换同时通知 ReplayController(暂停 transport、把曲线图交还实时会话),
  这是 B 之后两个控制器之间唯一的方向性依赖,写在构造参数里而不是隐式查找。
- What: 窗口侧保留过渡 shim(_view_preset / _show_cal / _show_finger_3d 可读写属性,
  _apply_view_preset / _apply_panel_visibility / _shows_3d / _splitter_sizes_for /
  _set_settings_expanded / _sync_settings_action / _sync_3d_source / _restore_3d_settings /
  view_preset()),供 tests 与 tools/ui_check.py 继续驱动窗口;窗口内部调用点
  (brand_bar 导航、mesh3d_panel.enabledChanged、toolbar.settings_toggled、
  _build_menus 里的三个 QAction、_on_sensor_type_changed、_sync_menu_checks)改为直连
  控制器。_sync_menu_checks 只保留主题/语言两项(那是窗口的状态),视图三项交给
  layout_ctl.sync_view_checks()。
- What: 新增 apps/studio/tests/test_view_layout.py:10 个用例,用假面板/假 splitter/
  假 host 直接测布局规则——预设决定面板组合与 rail 角色、未知预设回落 custom、离开回放
  视图会停 transport 并清掉重算标签、3D 面板需要 finger + 开关且隐藏时不建 GL 视图、
  预设不给 3D 留宽度时从最宽面板取且总量不变、标定面板开关即视图开关、3D 开关不自我
  驱动(no-op 不写偏好)、菜单勾选跟随屏幕且 blockSignals、设置抽屉三处同步、
  3D 面板拿到正确的传感器与密钥。
- Why: 「家具」是 app_window 的第三个独立变更驱动。搬完之后「哪个视图显示哪些面板」
  只有一处规则,窗口不再同时持有布局状态与面板。布局与回放两个控制器的依赖是显式的
  单向调用。
- Verification: pytest shared/tests apps/studio/tests(offscreen)621 passed / 0 failed /
  15 skipped(原 611 + 新增 10);apps/studio/tools/ui_check.py 587 项检查 0 失败;
  .repro/locale_check.py 0 缺失。行为等价(搬运 + 一层转发)。
- Not in this change: C2 显示偏好(单位/量程/箭头/2D 模式/fps)与标定交互仍未拆分;
  预览(_start_preview/_stop_preview/_on_preview_tick)留在窗口,它既不是会话也不是布局;
  docs 的行数/方法数指标待全部切片落定后统一刷新。
This commit is contained in:
ykcheungab
2026-09-15 09:45:05 +08:00
parent fbf808d878
commit aecde67c73
3 changed files with 865 additions and 249 deletions
+97 -249
View File
@@ -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
@@ -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"))
)
+370
View File
@@ -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