diff --git a/apps/studio/src/orisys_studio/app_window.py b/apps/studio/src/orisys_studio/app_window.py index 9625e92..161e70f 100644 --- a/apps/studio/src/orisys_studio/app_window.py +++ b/apps/studio/src/orisys_studio/app_window.py @@ -46,6 +46,7 @@ from orisys_app_shared.record.sample import to_record_sample from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs from orisys_studio.replay import ReplayEngine from orisys_studio.replay_controller import ReplayController +from orisys_studio.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._render_run_image(self._active_worker.latest()) + 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: diff --git a/apps/studio/src/orisys_studio/display_prefs.py b/apps/studio/src/orisys_studio/display_prefs.py new file mode 100644 index 0000000..4e98d14 --- /dev/null +++ b/apps/studio/src/orisys_studio/display_prefs.py @@ -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() diff --git a/apps/studio/tests/test_display_prefs.py b/apps/studio/tests/test_display_prefs.py new file mode 100644 index 0000000..34cbce0 --- /dev/null +++ b/apps/studio/tests/test_display_prefs.py @@ -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