拆分 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:
@@ -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"))
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user