拆分 C2:显示偏好移入 DisplayPrefsController(app_window 2347→2340 行 / 净减 110 行代码)

- What: 新增 apps/studio/src/orisys_studio/display_prefs.py:把「同一个测量值可以怎么
  显示」搬进 DisplayPrefsController——单位(newton / raw,含工具栏、曲线图、重算引擎、
  数值卡四处同步)、量程与时间窗(含默认值、应用到面板、供对话框读取)、箭头显示
  (按传感器决定旋转是否可用、图例倍率、旋转 90°)、2D 视图模式切换后的重绘、以及
  rail 上的帧率读数开关。控制器自己拥有 show_fps;其余偏好仍写在 preferences 里。
  `DisplayHost` Protocol 写明它向窗口借的五件事:_persist_prefs / _refresh_status_rail /
  _latest_fps(算滤波设计用的采样率)/ _drop_render_marker + _repaint_live_frame
  (2D 视图模式变了要重建那一帧)。
  _on_plot_settings *不搬*:它要开 QDialog、还要把滤波系数交给采集 worker,那是会话与
  窗口的事,不是显示偏好;它现在只向控制器取对话框要显示的值、把结果交回去应用。
- What: 窗口侧保留过渡 shim(_set_display_unit / _on_units_changed / _on_chart_unit_toggle /
  _on_image_view_mode_changed / _on_show_fps_toggled / _apply_plot_prefs_to_panel /
  _device_sample_rate / _apply_arrow_rotation_for_sensor / _apply_arrow_legend /
  _draw_style / _on_rotate_arrows,以及 _show_fps 可读写属性),供 tests 与
  tools/ui_check.py 继续驱动窗口(其中 window._on_plot_settings 仍可被测试整体替换)。
  窗口内部连线(toolbar.units_changed、arrow_panel.view_mode_changed、
  force_plots.unit_toggle_requested、act_show_fps.toggled、act_rotate_arrows.triggered)
  改为直连控制器;_sync_menu_checks 里的箭头部分也走控制器。
- What: 新增 apps/studio/tests/test_display_prefs.py:11 个用例——单位四处同步、
  工具栏改单位时不回写工具栏(防回声)、图表单位芯片翻转、对话框值取默认值、
  应用量程写偏好与面板、采样率优先用实测值再回落存储值与默认值、不可旋转的传感器
  不提供旋转且不写偏好、可旋转的重启后记住角度、图例倍率随 draw style 变化、
  切换 2D 模式只重绘一次、帧率开关同时更新偏好与 rail。
- Why: 「数字怎么显示」是 app_window 的第四个独立变更驱动:改一个显示偏好不该碰会话、
  回放或布局。搬完之后这条规则的唯一所有者在 display_prefs 里,且可以脱离窗口单测。
- Verification: pytest shared/tests apps/studio/tests(offscreen)632 passed / 0 failed /
  15 skipped(原 621 + 新增 11);apps/studio/tools/ui_check.py 587 项检查 0 失败;
  .repro/locale_check.py 0 缺失。行为等价(搬运 + 一层转发)。
- Not in this change: 标定交互(_on_calibration_apply / 身份与不匹配检查 / 导出与事件)
  仍未拆分;预览(_start_preview/_stop_preview/_on_preview_tick)留在窗口;
  _depth_range/_depth_scale_text 与 _show_image_frame 同属 2D 渲染,留在窗口;
  docs 的行数/方法数指标待全部切片落定后统一刷新。
This commit is contained in:
ykcheungab
2026-09-15 09:50:25 +08:00
parent aecde67c73
commit 0a42a452dd
3 changed files with 612 additions and 112 deletions
+104 -111
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.display_prefs import DisplayPrefsController
from orisys_studio.view_layout import ViewLayoutController
from orisys_studio import session_metadata
from orisys_studio.session_controller import SessionController
@@ -154,9 +155,9 @@ 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")
# 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))
# The view preset, the calibration panel, the 3D pane's switch and the fps
# readout are the controllers' state; the window reads and writes them through
# the shims below.
# Whether a recompute pass is running is the replay controller's state; the
# window reads and writes it through the ``_recompute_running`` shim below.
self._preview = None
@@ -266,6 +267,27 @@ class StudioMainWindow(QMainWindow):
parent=self,
)
self.replay_ctl.wire()
# How the numbers are shown (units, chart range, arrows, the fps readout) is a
# preference of its own: the dialog that changes the chart settings stays here,
# the values and what they do to the panels live there.
self.display_ctl = DisplayPrefsController(
host=self,
toolbar=self.toolbar,
charts=self.force_plots,
arrow_panel=self.arrow_panel,
engine=self.replay,
sensor_picker=self.sensor_picker,
plot_defaults={
"window_sec": DEFAULT_PLOT_WINDOW_SEC,
"fn_max": DEFAULT_PLOT_FN_MAX,
"shear_max": DEFAULT_PLOT_SHEAR_MAX,
"fn_raw_max": DEFAULT_PLOT_FN_RAW_MAX,
"shear_raw_max": DEFAULT_PLOT_SHEAR_RAW_MAX,
},
default_sample_rate_hz=float(FORCE_SAMPLE_RATE_HZ),
arrow_rotate_types=ARROW_ROTATE_TYPES,
parent=self,
)
# 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.
@@ -716,16 +738,20 @@ class StudioMainWindow(QMainWindow):
self.toolbar.run_toggled.connect(self._on_run_toggled)
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.units_changed.connect(self.display_ctl.on_units_changed)
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)
self.sensor_picker.config_changed.connect(self._on_config_changed)
self.arrow_panel.view_mode_changed.connect(self._on_image_view_mode_changed)
self.arrow_panel.view_mode_changed.connect(
self.display_ctl.on_image_view_mode_changed
)
self.arrow_panel.view_mode_changed.connect(lambda _mode: self.replay_ctl.render_frame())
# The unit chip on the chart header switches the same unit everywhere.
self.force_plots.unit_toggle_requested.connect(self._on_chart_unit_toggle)
self.force_plots.unit_toggle_requested.connect(
self.display_ctl.on_chart_unit_toggle
)
# The gear on the chart header opens the same dialog as 视图 ▸ 曲线图设置.
self.force_plots.plot_settings_requested.connect(self._on_plot_settings)
@@ -875,7 +901,7 @@ class StudioMainWindow(QMainWindow):
self.act_show_fps = QAction(self)
self.act_show_fps.setCheckable(True)
self.act_show_fps.setChecked(self._show_fps)
self.act_show_fps.toggled.connect(self._on_show_fps_toggled)
self.act_show_fps.toggled.connect(self.display_ctl.on_show_fps_toggled)
self.view_menu.addAction(self.act_show_fps)
self.act_settings = QAction(self)
@@ -888,7 +914,7 @@ class StudioMainWindow(QMainWindow):
self.view_menu.addAction(self.act_plot_settings)
self.act_rotate_arrows = QAction(self)
self.act_rotate_arrows.triggered.connect(self._on_rotate_arrows)
self.act_rotate_arrows.triggered.connect(self.display_ctl.on_rotate_arrows)
self.view_menu.addAction(self.act_rotate_arrows)
# The replay reads the recorded CSV (the values the session actually
@@ -970,6 +996,9 @@ class StudioMainWindow(QMainWindow):
finger_3d=self.act_finger_3d,
settings=self.act_settings,
)
# The arrow-rotation entry follows the sensor, which is a display preference
# (which draw style the legend describes) rather than a layout one.
self.display_ctl.bind_actions(rotate_arrows=self.act_rotate_arrows)
self._sync_menu_checks()
def _sync_menu_checks(self) -> None:
@@ -1367,77 +1396,96 @@ class StudioMainWindow(QMainWindow):
self.sensor_dialog.raise_()
self.sensor_dialog.activateWindow()
# ------------------------------------------------- display (forwards)
# How the numbers are shown lives in ``DisplayPrefsController``; these forward for
# the call sites still in this file and for the tests / ``tools/ui_check``, which
# drive the window. ``_on_plot_settings`` stays here: it opens a dialog and hands the
# filters to the acquisition workers, which is not a display preference.
def _set_display_unit(self, unit: str, *, sync_toolbar: bool = True) -> None:
self.display_ctl.set_display_unit(unit, sync_toolbar=sync_toolbar)
def _on_units_changed(self, units: str) -> None:
self._set_display_unit(
"N" if str(units) == "newton" else "raw", sync_toolbar=False
)
self.display_ctl.on_units_changed(units)
def _on_chart_unit_toggle(self) -> None:
"""The chart's own unit chip: newton <-> raw."""
self._set_display_unit("raw" if self.force_plots.unit() == "N" else "N")
def _set_display_unit(self, unit: str, *, sync_toolbar: bool = True) -> None:
"""One display unit for the toolbar, the charts and the value cards.
The toolbar combo can already *be* on the requested unit (for instance
after a replay's unit switch), in which case its signal never fires — so
the charts are told directly instead of relying on that signal.
"""
if sync_toolbar:
self.toolbar.set_units("newton" if unit == "N" else "raw")
if self.replay.is_loaded():
# One unit for the charts, the rail and the replay controller.
self.replay.set_unit(unit)
self.prefs["units"] = self.toolbar.units()
self.force_plots.set_fixed_newton(unit == "N")
# Rebuild the visible curves from the attached source (the session store
# or the recording being replayed), which keeps both triplets, instead of
# appending new-unit values onto old ones.
self.force_plots.set_units(unit)
self._persist_prefs()
self.display_ctl.on_chart_unit_toggle()
def _on_image_view_mode_changed(self, mode: str) -> None:
self.prefs["image_view_mode"] = str(mode)
self._last_rendered_view_mode = None
self._persist_prefs()
if self._running:
if self._active_worker is not None:
self.display_ctl.on_image_view_mode_changed(mode)
def _on_show_fps_toggled(self, visible: bool) -> None:
self.display_ctl.on_show_fps_toggled(visible)
def _apply_plot_prefs_to_panel(self) -> None:
self.display_ctl.apply_plot_prefs_to_panel()
def _device_sample_rate(self) -> float:
return self.display_ctl.device_sample_rate()
def _apply_arrow_rotation_for_sensor(self) -> None:
self.display_ctl.apply_arrow_rotation_for_sensor()
def _apply_arrow_legend(self) -> None:
self.display_ctl.apply_arrow_legend()
def _draw_style(self) -> str:
return self.display_ctl.draw_style()
def _on_rotate_arrows(self) -> None:
self.display_ctl.on_rotate_arrows()
def _repaint_live_frame(self) -> None:
"""Repaint the 2D panel from the newest packet (the view mode changed).
The panel is showing a frame drawn in the *previous* mode, so it has to be
rebuilt; only a running session has a newer packet to build it from.
"""
if not self._running or self._active_worker is None:
return
self._render_run_image(self._active_worker.latest())
@property
def _show_fps(self) -> bool:
return self.display_ctl.show_fps
@_show_fps.setter
def _show_fps(self, visible: bool) -> None:
self.display_ctl.show_fps = visible
def _on_plot_settings(self) -> None:
settings = self.display_ctl.plot_settings()
dialog = PlotSettingsDialog(
self,
window_sec=float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC),
fn_max=float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
shear_max=float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
fn_raw_max=float(
self.prefs.get("plot_fn_raw_max") or DEFAULT_PLOT_FN_RAW_MAX
),
shear_raw_max=float(
self.prefs.get("plot_shear_raw_max") or DEFAULT_PLOT_SHEAR_RAW_MAX
),
units=self.toolbar.units(),
window_sec=settings["window_sec"],
fn_max=settings["fn_max"],
shear_max=settings["shear_max"],
fn_raw_max=settings["fn_raw_max"],
shear_raw_max=settings["shear_raw_max"],
units=settings["units"],
filters=self._filter_settings(),
# Asked *now*: the answer is a property of this moment, and the
# dialog is the place that has to say "next session, not this one".
filters_locked=bool(self._running or self.recorder.is_recording),
sample_rate_hz=self._device_sample_rate(),
sample_rate_hz=self.display_ctl.device_sample_rate(),
)
if dialog.exec_() != PlotSettingsDialog.Accepted:
return
window_sec, fn_max, shear_max, fn_raw_max, shear_raw_max = dialog.values()
self.prefs["plot_window_sec"] = window_sec
self.prefs["plot_fn_max"] = fn_max
self.prefs["plot_shear_max"] = shear_max
self.prefs["plot_fn_raw_max"] = fn_raw_max
self.prefs["plot_shear_raw_max"] = shear_raw_max
self.display_ctl.apply_plot_settings(
{
"window_sec": window_sec,
"fn_max": fn_max,
"shear_max": shear_max,
"fn_raw_max": fn_raw_max,
"shear_raw_max": shear_raw_max,
}
)
filters = dialog.filter_values()
# The same keys ``_filter_settings()`` reads back, so what the dialog
# shows next time is what the session will actually be given.
for key, value in filters.as_dict().items():
self.prefs[f"plot_{key}"] = value
self.force_plots.set_window_sec(window_sec)
self.force_plots.set_axis_limits(fn_max, shear_max, fn_raw_max, shear_raw_max)
self._last_plot_packet_key = None
self._persist_prefs()
if not self._running and not self.recorder.is_recording:
@@ -1451,26 +1499,6 @@ class StudioMainWindow(QMainWindow):
t("status.filters_saved", describe=filters.describe())
)
def _device_sample_rate(self) -> float:
"""The frame rate the low-pass coefficients would be designed for."""
return session_metadata.device_sample_rate(
self._latest_fps,
self.prefs.get("measured_fps"),
default=float(FORCE_SAMPLE_RATE_HZ),
)
def _apply_plot_prefs_to_panel(self) -> None:
self.force_plots.set_window_sec(
float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC)
)
self.force_plots.set_axis_limits(
float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
float(self.prefs.get("plot_fn_raw_max") or DEFAULT_PLOT_FN_RAW_MAX),
float(self.prefs.get("plot_shear_raw_max") or DEFAULT_PLOT_SHEAR_RAW_MAX),
)
self.force_plots.set_fixed_newton(self.toolbar.units() == "newton")
def _on_calibration_exported(self, path: str) -> None:
"""Note the coefficients that were written. Nothing is applied here.
@@ -1655,13 +1683,6 @@ class StudioMainWindow(QMainWindow):
return
view.submit_packet(packet)
def _on_show_fps_toggled(self, visible: bool) -> None:
self._show_fps = bool(visible)
self.prefs["show_fps"] = self._show_fps
# FPS has one home now: the status rail.
self._refresh_status_rail()
self._persist_prefs()
def _on_sensor_type_changed(self, sensor_type: str) -> None:
self.prefs["sensor_type"] = str(sensor_type)
self.prefs["config_name"] = self.sensor_picker.config_name()
@@ -1680,34 +1701,6 @@ class StudioMainWindow(QMainWindow):
self._refresh_status_rail()
self._persist_prefs()
def _apply_arrow_rotation_for_sensor(self) -> None:
enabled = self.sensor_picker.sensor_type() in ARROW_ROTATE_TYPES
self.arrow_panel.set_rotation_enabled(enabled)
self.act_rotate_arrows.setEnabled(enabled)
self._apply_arrow_legend()
def _draw_style(self) -> str:
"""Which arrow draw style the configured sensor uses."""
return "finger" if self.sensor_picker.sensor_type() == "finger" else "box"
def _apply_arrow_legend(self) -> None:
"""The arrow legend describes the *configured* sensor, from the start.
The magnification is a property of the draw style, not of the first
frame that happens to arrive, so a finger sensor must not advertise the
box magnification while it is still idle.
"""
from orisys_app_shared.acquisition.arrows import arrow_display_scale
self.arrow_panel.set_arrow_scale(arrow_display_scale(self._draw_style()))
def _on_rotate_arrows(self) -> None:
if self.sensor_picker.sensor_type() not in ARROW_ROTATE_TYPES:
return
deg = self.arrow_panel.rotate_90_ccw()
self.prefs["arrow_rotation_deg"] = deg
self._persist_prefs()
# ----------------------------------------------------------- shortcuts
def _sync_shortcut_actions(self) -> None:
if getattr(self, "act_run", None) is None:
@@ -0,0 +1,227 @@
"""How the numbers are *shown*: units, chart ranges, arrows, and the readouts.
The panels render; this controller decides what the operator is looking at when the same
measurement can be presented two ways — newtons or raw counts, a rolling window or a
fixed range, arrows drawn for a box or for a finger, the frame rate on the rail or not.
That is a change driver of its own: a preference changes without touching the acquisition
session, the replay policy or the panel arrangement.
It owns the dialogue-free half of the plot settings (the values, their defaults and what
they do to the panel) and leaves the *dialog* to the window: opening a QDialog and handing
the filters to the acquisition workers is window work, and the session's filters are not a
display preference.
What it borrows from the window is named by :class:`DisplayHost` — persisting the choice,
the rail, the filter settings the dialog shows, the latest measured rate, and a live
repaint when the 2D view mode changes.
"""
from __future__ import annotations
import logging
from typing import Any, Optional, Protocol
from PyQt5.QtCore import QObject
logger = logging.getLogger(__name__)
class DisplayHost(Protocol):
"""What the display 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
@property
def _latest_fps(self) -> Optional[float]: ...
def _persist_prefs(self) -> None: ...
def _refresh_status_rail(self) -> None: ...
def _drop_render_marker(self) -> None: ...
def _repaint_live_frame(self) -> None: ...
class DisplayPrefsController(QObject):
"""Units, chart range and window, arrow display, and the fps readout."""
def __init__(
self,
*,
host: DisplayHost,
toolbar: Any,
charts: Any,
arrow_panel: Any,
engine: Any,
sensor_picker: Any,
plot_defaults: dict,
default_sample_rate_hz: float,
arrow_rotate_types: frozenset,
rotate_arrows_action: Any = None,
parent: Optional[QObject] = None,
) -> None:
super().__init__(parent)
self._host = host
self._toolbar = toolbar
self._charts = charts
self._arrow_panel = arrow_panel
self._engine = engine
self._picker = sensor_picker
self._rotate_action = rotate_arrows_action
self._plot_defaults = dict(plot_defaults)
self._default_sample_rate_hz = float(default_sample_rate_hz)
self._arrow_rotate_types = frozenset(arrow_rotate_types)
#: Whether the rail shows the frame rate: a display preference, so it lives with
#: the other ones rather than with the session that produces the number.
self._show_fps = bool(host.prefs.get("show_fps", False))
def bind_actions(self, *, rotate_arrows: Any) -> None:
"""Take the rotate entry once the menus exist (it follows the sensor)."""
self._rotate_action = rotate_arrows
# ------------------------------------------------------------------- state
@property
def show_fps(self) -> bool:
return self._show_fps
@show_fps.setter
def show_fps(self, visible: bool) -> None:
self._show_fps = bool(visible)
# ------------------------------------------------------------------- units
def set_display_unit(self, unit: str, *, sync_toolbar: bool = True) -> None:
"""One display unit for the toolbar, the charts and the value cards.
The toolbar combo can already *be* on the requested unit (for instance after a
replay's unit switch), in which case its signal never fires — so the charts are
told directly instead of relying on that signal.
"""
if sync_toolbar:
self._toolbar.set_units("newton" if unit == "N" else "raw")
if self._engine.is_loaded():
# One unit for the charts, the rail and the replay controller.
self._engine.set_unit(unit)
self._host.prefs["units"] = self._toolbar.units()
self._charts.set_fixed_newton(unit == "N")
# Rebuild the visible curves from the attached source (the session store or the
# recording being replayed), which keeps both triplets, instead of appending
# new-unit values onto old ones.
self._charts.set_units(unit)
self._host._persist_prefs()
def on_units_changed(self, units: str) -> None:
self.set_display_unit(
"N" if str(units) == "newton" else "raw", sync_toolbar=False
)
def on_chart_unit_toggle(self) -> None:
"""The chart's own unit chip: newton <-> raw."""
self.set_display_unit("raw" if self._charts.unit() == "N" else "N")
# ------------------------------------------------------------- 2D view mode
def on_image_view_mode_changed(self, mode: str) -> None:
self._host.prefs["image_view_mode"] = str(mode)
# The next packet must repaint the panel: the frame that is on screen was drawn
# in the previous mode.
self._host._drop_render_marker()
self._host._persist_prefs()
self._host._repaint_live_frame()
# --------------------------------------------------------------------- plot
def plot_settings(self) -> dict:
"""The chart range and window as the settings dialog should show them."""
prefs = self._host.prefs
return {
"window_sec": float(
prefs.get("plot_window_sec") or self._plot_defaults["window_sec"]
),
"fn_max": float(prefs.get("plot_fn_max") or self._plot_defaults["fn_max"]),
"shear_max": float(
prefs.get("plot_shear_max") or self._plot_defaults["shear_max"]
),
"fn_raw_max": float(
prefs.get("plot_fn_raw_max") or self._plot_defaults["fn_raw_max"]
),
"shear_raw_max": float(
prefs.get("plot_shear_raw_max") or self._plot_defaults["shear_raw_max"]
),
"units": self._toolbar.units(),
}
def apply_plot_settings(self, values: dict) -> None:
"""Remember the chart range the operator chose, and show it at once."""
for key in ("window_sec", "fn_max", "shear_max", "fn_raw_max", "shear_raw_max"):
self._host.prefs[f"plot_{key}"] = values[key]
self._charts.set_window_sec(float(values["window_sec"]))
self._charts.set_axis_limits(
float(values["fn_max"]),
float(values["shear_max"]),
float(values["fn_raw_max"]),
float(values["shear_raw_max"]),
)
self._host._persist_prefs()
def apply_plot_prefs_to_panel(self) -> None:
"""Show the stored chart range without asking anything (startup path)."""
values = self.plot_settings()
self._charts.set_window_sec(float(values["window_sec"]))
self._charts.set_axis_limits(
float(values["fn_max"]),
float(values["shear_max"]),
float(values["fn_raw_max"]),
float(values["shear_raw_max"]),
)
self._charts.set_fixed_newton(self._toolbar.units() == "newton")
def device_sample_rate(self) -> float:
"""The frame rate the low-pass coefficients would be designed for."""
from orisys_studio import session_metadata
return session_metadata.device_sample_rate(
self._host._latest_fps,
self._host.prefs.get("measured_fps"),
default=self._default_sample_rate_hz,
)
# ------------------------------------------------------------------- arrows
def draw_style(self) -> str:
"""Which arrow draw style the configured sensor uses."""
return "finger" if self._picker.sensor_type() == "finger" else "box"
def apply_arrow_rotation_for_sensor(self) -> None:
enabled = self._picker.sensor_type() in self._arrow_rotate_types
self._arrow_panel.set_rotation_enabled(enabled)
if self._rotate_action is not None:
self._rotate_action.setEnabled(enabled)
self.apply_arrow_legend()
def apply_arrow_legend(self) -> None:
"""The arrow legend describes the *configured* sensor, from the start.
The magnification is a property of the draw style, not of the first frame that
happens to arrive, so a finger sensor must not advertise the box magnification
while it is still idle.
"""
from orisys_app_shared.acquisition.arrows import arrow_display_scale
self._arrow_panel.set_arrow_scale(arrow_display_scale(self.draw_style()))
def on_rotate_arrows(self) -> None:
if self._picker.sensor_type() not in self._arrow_rotate_types:
return
deg = self._arrow_panel.rotate_90_ccw()
self._host.prefs["arrow_rotation_deg"] = deg
self._host._persist_prefs()
# ---------------------------------------------------------------------- fps
def on_show_fps_toggled(self, visible: bool) -> None:
self._show_fps = bool(visible)
self._host.prefs["show_fps"] = self._show_fps
# FPS has one home now: the status rail.
self._host._refresh_status_rail()
self._host._persist_prefs()
+280
View File
@@ -0,0 +1,280 @@
"""Acceptance for the display preferences, on their own.
``DisplayPrefsController`` decides how a measurement is *presented*: newtons or raw
counts, which chart range, which arrow style, whether the rail shows the frame rate. It
drives the panels through their own interfaces and borrows a few window-level operations
through ``DisplayHost``, so both can be stood in for — which these tests do.
"""
from __future__ import annotations
from orisys_studio.display_prefs import DisplayPrefsController
PLOT_DEFAULTS = {
"window_sec": 10.0,
"fn_max": 5.0,
"shear_max": 5.0,
"fn_raw_max": 1e5,
"shear_raw_max": 1e5,
}
class _Action:
def __init__(self) -> None:
self.enabled: bool | None = None
def setEnabled(self, value: bool) -> None: # noqa: N802
self.enabled = bool(value)
class _Toolbar:
def __init__(self, units: str = "newton") -> None:
self._units = units
self.set_units_calls: list[str] = []
def units(self) -> str:
return self._units
def set_units(self, units: str) -> None:
self.set_units_calls.append(str(units))
self._units = str(units)
class _Charts:
def __init__(self, unit: str = "N") -> None:
self._unit = unit
self.fixed_newton: list[bool] = []
self.units: list[str] = []
self.window_sec: list[float] = []
self.limits: list[tuple] = []
def unit(self) -> str:
return self._unit
def set_fixed_newton(self, value: bool) -> None: # noqa: N802
self.fixed_newton.append(bool(value))
def set_units(self, unit: str) -> None:
self.units.append(str(unit))
self._unit = str(unit)
def set_window_sec(self, value: float) -> None: # noqa: N802
self.window_sec.append(float(value))
def set_axis_limits(self, *values) -> None: # noqa: N802
self.limits.append(tuple(float(v) for v in values))
class _ArrowPanel:
def __init__(self) -> None:
self.rotation_enabled: list[bool] = []
self.scales: list[float] = []
self.rotations = 0
def set_rotation_enabled(self, enabled: bool) -> None:
self.rotation_enabled.append(bool(enabled))
def set_arrow_scale(self, scale: float) -> None:
self.scales.append(float(scale))
def rotate_90_ccw(self) -> int:
self.rotations += 1
return 180
class _Engine:
def __init__(self, loaded: bool = False) -> None:
self._loaded = loaded
self.units: list[str] = []
def is_loaded(self) -> bool:
return self._loaded
def set_unit(self, unit: str) -> None:
self.units.append(str(unit))
class _Picker:
def __init__(self, sensor_type: str = "box") -> None:
self._sensor = sensor_type
def sensor_type(self) -> str:
return self._sensor
class _Host:
def __init__(self, prefs: dict, latest_fps=None) -> None:
self.prefs = dict(prefs)
self.persisted = 0
self.rail_updates = 0
self.markers = 0
self.repaints = 0
self._latest_fps = latest_fps
def _persist_prefs(self) -> None:
self.persisted += 1
def _refresh_status_rail(self) -> None:
self.rail_updates += 1
def _drop_render_marker(self) -> None:
self.markers += 1
def _repaint_live_frame(self) -> None:
self.repaints += 1
def _controller(*, prefs=None, sensor_type="box", units="newton", loaded=False, latest_fps=None):
host = _Host(prefs or {}, latest_fps=latest_fps)
toolbar = _Toolbar(units)
charts = _Charts("N" if units == "newton" else "raw")
arrows = _ArrowPanel()
engine = _Engine(loaded)
action = _Action()
controller = DisplayPrefsController(
host=host,
toolbar=toolbar,
charts=charts,
arrow_panel=arrows,
engine=engine,
sensor_picker=_Picker(sensor_type),
rotate_arrows_action=action,
plot_defaults=PLOT_DEFAULTS,
default_sample_rate_hz=30.0,
arrow_rotate_types=frozenset({"box", "mini"}),
)
return controller, {
"host": host,
"toolbar": toolbar,
"charts": charts,
"arrows": arrows,
"engine": engine,
"action": action,
}
# --------------------------------------------------------------------- tests
def test_the_unit_reaches_the_toolbar_the_charts_and_the_replay():
controller, parts = _controller(prefs={}, units="newton", loaded=True)
controller.set_display_unit("raw")
assert parts["toolbar"].set_units_calls == ["raw"]
assert parts["engine"].units == ["raw"], "an open recording was not told"
assert parts["charts"].fixed_newton == [False]
assert parts["charts"].units == ["raw"]
assert parts["host"].prefs["units"] == "raw"
assert parts["host"].persisted == 1
def test_a_unit_change_from_the_toolbar_does_not_talk_back_to_it():
"""The combo already *is* on that unit; writing it again is how a loop starts."""
controller, parts = _controller(prefs={}, units="raw")
controller.on_units_changed("newton")
assert parts["toolbar"].set_units_calls == [], parts["toolbar"].set_units_calls
assert parts["charts"].units == ["N"]
assert parts["charts"].fixed_newton == [True]
def test_the_chart_chip_flips_which_unit_the_charts_use():
controller, parts = _controller(prefs={}, units="newton")
controller.on_chart_unit_toggle()
assert parts["charts"].units == ["raw"]
controller.on_chart_unit_toggle()
assert parts["charts"].units == ["raw", "N"]
def test_the_plot_dialog_values_come_from_the_preferences_with_defaults():
controller, parts = _controller(prefs={"plot_window_sec": 3.0})
settings = controller.plot_settings()
assert settings["window_sec"] == 3.0
assert settings["fn_max"] == PLOT_DEFAULTS["fn_max"], "a missing range used no default"
assert settings["units"] == "newton"
def test_applying_plot_settings_writes_the_preferences_and_the_panel():
controller, parts = _controller(prefs={})
controller.apply_plot_settings(
{
"window_sec": 20.0,
"fn_max": 8.0,
"shear_max": 9.0,
"fn_raw_max": 2e5,
"shear_raw_max": 3e5,
}
)
assert parts["host"].prefs["plot_window_sec"] == 20.0
assert parts["host"].prefs["plot_shear_raw_max"] == 3e5
assert parts["charts"].window_sec == [20.0]
assert parts["charts"].limits == [(8.0, 9.0, 2e5, 3e5)]
assert parts["host"].persisted == 1
def test_the_sample_rate_prefers_the_measured_rate_over_the_stored_one():
controller, parts = _controller(prefs={"measured_fps": 12.0}, latest_fps=48.5)
assert controller.device_sample_rate() == 48.5
parts["host"]._latest_fps = None
assert controller.device_sample_rate() == 12.0
parts["host"].prefs.pop("measured_fps")
assert controller.device_sample_rate() == 30.0, "the default did not apply"
def test_rotation_is_offered_only_where_the_sensor_can_be_rotated():
controller, parts = _controller(sensor_type="finger")
controller.apply_arrow_rotation_for_sensor()
assert parts["arrows"].rotation_enabled[-1] is False
assert parts["action"].enabled is False
parts["arrows"].rotations = 0
controller.on_rotate_arrows()
assert parts["arrows"].rotations == 0, "a finger sensor was rotated anyway"
assert "arrow_rotation_deg" not in parts["host"].prefs
def test_rotating_remembers_the_angle_and_a_box_sensor_may_rotate():
controller, parts = _controller(sensor_type="box")
controller.apply_arrow_rotation_for_sensor()
assert parts["arrows"].rotation_enabled[-1] is True
assert parts["action"].enabled is True
controller.on_rotate_arrows()
assert parts["host"].prefs["arrow_rotation_deg"] == 180
assert parts["host"].persisted == 1
def test_the_arrow_legend_describes_the_configured_sensor():
controller, parts = _controller(sensor_type="finger")
controller.apply_arrow_legend()
finger_scale = parts["arrows"].scales[-1]
controller, parts = _controller(sensor_type="box")
controller.apply_arrow_legend()
box_scale = parts["arrows"].scales[-1]
assert finger_scale != box_scale, "the two draw styles share one magnification"
def test_a_new_2d_view_mode_repaints_the_panel_once():
controller, parts = _controller(prefs={})
controller.on_image_view_mode_changed("depth")
assert parts["host"].prefs["image_view_mode"] == "depth"
assert parts["host"].markers == 1, "the stale frame was kept"
assert parts["host"].repaints == 1
assert parts["host"].persisted == 1
def test_the_fps_readout_is_a_preference_like_any_other():
controller, parts = _controller(prefs={})
assert controller.show_fps is False
controller.on_show_fps_toggled(True)
assert controller.show_fps is True
assert parts["host"].prefs["show_fps"] is True
assert parts["host"].rail_updates == 1, "the rail was not told to show it"
assert parts["host"].persisted == 1