From e86ba572cd0ccec55db94c4ca2813e8664dafc33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Fri, 24 Jul 2026 20:14:28 +0200 Subject: [PATCH 1/4] feat(viewer): stable y-scaling, width-relative detail, multi-panel layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A signal-viewer pass covering three things: how the axis behaves, how much gets drawn, and how several panels compose. Scaling - Auto no longer hands the axis to ImPlot's per-frame auto_fit. The range snaps out instantly to contain a new peak (never clipped) but contracts over ~5 s, so a variable signal in a small window stops zoom-jittering. Per-channel mode gets the same easing — its lane ranges no longer breathe — and its range scan is vectorised (~15x cheaper at 256 channels). - Per-Ch is now a scaling *basis*, not a mode that greys out Auto/Manual. Per-channel Manual freezes each lane's range so a weakening channel stays visible against its captured reference; Rescale re-fits and locks; Gain is live in per-channel Manual and inert in per-channel Auto. - Range fitting rejects transients shorter than a budget ("Reject <", default 20 ms) by *duration* rather than amplitude, so a brief movement artifact no longer blows up the scale and dwarfs the EMG. Mode-aware (rectify/rms_env pin the lower bound to 0), per-channel then unioned for the shared axis, and cheap enough for the per-frame path (~11 ms at 256 ch). 0 ms restores min/max. Detail - The fixed "Point cap" slider (100-10000 pts) becomes "Detail": draw density relative to the plot width, shown as a percentage of full. The old cap fought the width-derived target — min(n_pixels, width*3) — so the top half of its range was inert on a typical plot and the default under-resolved wide ones. n_pixels survives as an optional hard-cap override (default: no cap). Layout - Several viewers can share one stream. State and ImGui ids now key off a new widget_id (defaulting to stream_name) rather than the stream, so one panel per electrode grid works, each keeping its own channels, scale, filter and pause; previously every SignalViewer("emg") resolved to one state and rendered identically. title names a panel, show_controls opens it collapsed. - The panel chrome — title, control menu, channel bar, footer — collapses behind the header toggle, leaving a tiled panel almost all plot. Collapsed, the toggle shrinks to a bare icon so there is always a way back. - The channel-grid selector tiles several grids near-square (6 -> 3x2) instead of one tall stack, and opens sized to the tiling. - Heatmap.ui gains vrange, an explicit colour range. Several heatmaps must share one to be comparable: with per-instance autoscaling a quiet electrode array and a loud one render identically. --- CHANGELOG.md | 61 +++++ myogestic/widgets/plots/heatmap.py | 18 +- myogestic/widgets/signals/_channel_grid.py | 12 + myogestic/widgets/signals/_controls.py | 130 ++++++--- myogestic/widgets/signals/_plot.py | 272 +++++++++++++++++-- myogestic/widgets/signals/_state.py | 78 ++++-- myogestic/widgets/signals/viewer.py | 123 +++++++-- tests/test_auto_scale_ease.py | 295 +++++++++++++++++++++ tests/test_channel_grid.py | 17 ++ tests/test_signal_viewer_decimation.py | 51 +++- tests/test_viz.py | 71 +++++ 11 files changed, 1028 insertions(+), 100 deletions(-) create mode 100644 tests/test_auto_scale_ease.py diff --git a/CHANGELOG.md b/CHANGELOG.md index bc875d4c..394eae92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- **Signal viewer — several viewers can share one stream.** New `SignalViewer(widget_id=…, + title=…, show_controls=…)`. State and ImGui ids now key off `widget_id` (defaulting to + `stream_name`) instead of the stream, so N viewers can show one stream through N panels — e.g. + **one viewer per electrode grid**, tiled with the existing `Grid` layout — each keeping its own + channels, scale, filter and pause. Previously every `SignalViewer("emg")` resolved to the same + state and rendered identically. `title` names each panel and `show_controls=False` opens a tile + with the control menu collapsed. Note the panels do **not** share a y-scale: give them a common + manual range before comparing amplitudes across tiles. +- **Heatmap — shared colour range.** `Heatmap.ui(..., vrange=(lo, hi))` maps colours to an explicit + range instead of each frame's own min/max. Needed whenever several heatmaps are compared (with + per-instance autoscaling a quiet electrode array and a loud one render identically); also stops a + single heatmap's colours drifting frame to frame. +- **Signal viewer — multi-grid selector tiles near-square.** The channel-grid window (`[Edit…]`) + now lays several grids out **side by side** in a near-square block (`ceil(sqrt(n))` columns — e.g. + 6 grids as **3 × 2**) instead of one tall vertical stack, and opens sized to fit the tiling. Makes + a multi-adapter layout (e.g. Quattrocento `IN1…IN6`) usable without endless scrolling; per-grid + spatial click/drag is unchanged. +- **Signal viewer — hide the panel chrome.** A `≡` button on the `SIGNAL` header collapses + *everything* around the plot — title, control menu (scale / filter / detail / window), channel bar + and footer — so a small tiled panel is nearly all trace. Collapsed, the button shrinks to a bare + icon so there is always a way back. `SignalViewer(show_controls=False)` opens collapsed. + +- **Signal viewer — eased auto y-scale.** Auto scale mode no longer hands the y-axis to ImPlot's + per-frame `auto_fit`, which made a variable signal in a small window zoom in/out constantly. It + now **grows fast, shrinks slow**: the range **snaps out instantly** to contain a new peak (never + clipped) but contracts over ~5 s (so it never jitters downward). Switching the shown stream / + channels / display-filter / notch / gain / RMS window snaps once, then settles. Manual mode and + the Rescale button (snap-and-hold) are unchanged (and Rescale is now gain-correct). + **Per-channel** mode gets the same treatment: each channel's normalisation range is eased + (snap-out / slow-shrink) and the axis is pinned to the lane geometry, instead of recomputed + every frame, so per-lane amplitudes no longer breathe. The underlying per-channel range scan is + now vectorized (a single NaN-aware axis-0 reduction), cutting per-frame cost ~15× at 256 + channels so per-channel mode stays smooth on high-density grids. +- **Signal viewer — per-channel Auto/Manual.** Per-Ch no longer greys out Auto/Manual/Rescale: it + is the scaling *basis* (shared vs one lane per channel) and Auto/Manual is the adaptation policy + applied to either. Per-channel **Manual** freezes each lane's current range, so a channel + weakening / strengthening / drifting stays visible against its captured reference (instead of + always being re-normalised to fill its lane); **Rescale** ("Fit & lock") re-fits every channel + and locks; **Gain** is live in per-channel Manual (magnifies each trace against its frozen + range) and inert in per-channel Auto. +- **Signal viewer — artifact-robust y-scale.** Fitting the range (Auto, per-channel, and Rescale) + now ignores transients shorter than a configurable budget (**"Reject <" control, default 20 ms**), + so a brief movement-artifact spike no longer blows up the scale and dwarfs the real EMG. It works + by *duration*, not amplitude — the visible window is split into equal-time bins and the few most + extreme bin-maxima/minima that a sub-budget transient could occupy are dropped — because a 10 ms + artifact and a 10 ms real event are indistinguishable by amplitude alone. Mode-aware + (rectify/rms_env pin the lower bound to 0), per-channel then unioned for the shared axis (never a + flattened percentile that would drown out a contraction on one of many channels), and cheap + enough for the per-frame path (~11 ms at 256 ch). `0 ms` restores plain min/max. +- **Signal viewer — width-relative "Detail" control.** The fixed **"Point cap"** slider (100–10000 + points) is replaced by a **"Detail"** slider (shown as a percentage of full, default 100%) that + sets draw density *relative to the plot width* (full = a few points per pixel). The old cap fought + the width-derived target + (`min(n_pixels, plot_width × 3)`), so on a typical plot the top half of its range was a dead zone + (raising it past `plot_width × 3` did nothing) and the default under-resolved wide plots; the new + control is meaningful end-to-end — full detail always tracks the plot, and you only turn it *down* + for a coarser, cheaper trace when many channels tax the frame rate. The `SignalViewer(n_pixels=…)` + constructor arg is demoted to an optional hard-cap override (default `None` = no cap). + ## [2.3.2] - 2026-07-23 ### Changed diff --git a/myogestic/widgets/plots/heatmap.py b/myogestic/widgets/plots/heatmap.py index e3e78a42..aeec6e62 100644 --- a/myogestic/widgets/plots/heatmap.py +++ b/myogestic/widgets/plots/heatmap.py @@ -65,6 +65,7 @@ def ui( data: np.ndarray, x_tick_labels: list[str] | None = None, y_tick_labels: list[str] | None = None, + vrange: tuple[float, float] | None = None, ) -> None: """Render the heatmap for the given frame. @@ -76,6 +77,12 @@ def ui( Per-column / per-row axis labels (e.g. class names for a confusion matrix). When omitted, columns/rows are labelled by index. Extra labels beyond the grid size are ignored. + vrange : tuple[float, float], optional + Explicit ``(min, max)`` for the colour mapping. ``None`` (default) + maps this frame's own ``data.min()``/``.max()``. Pass a **shared** + range whenever several heatmaps are meant to be compared — with + per-instance autoscaling a quiet grid and a loud one render + identically — or a fixed one to stop colours drifting frame to frame. """ imgui.push_id(self._widget_id or self._label) try: @@ -115,11 +122,18 @@ def ui( implot.setup_axis_ticks( implot.ImAxis_.y1, [rows - i - 0.5 for i in range(rows)], y_labels[:rows] ) + lo, hi = ( + (float(values.min()), float(values.max())) + if vrange is None + else (float(vrange[0]), float(vrange[1])) + ) + if not hi > lo: # degenerate (flat data / empty range): keep implot sane + hi = lo + 1e-12 implot.plot_heatmap( "##heatmap", values, - scale_min=float(values.min()), - scale_max=float(values.max()), + scale_min=lo, + scale_max=hi, label_fmt=self._label_fmt, bounds_min=implot.Point(0.0, 0.0), bounds_max=implot.Point(float(cols), float(rows)), diff --git a/myogestic/widgets/signals/_channel_grid.py b/myogestic/widgets/signals/_channel_grid.py index be1a58f8..c83de978 100644 --- a/myogestic/widgets/signals/_channel_grid.py +++ b/myogestic/widgets/signals/_channel_grid.py @@ -33,6 +33,18 @@ def auto_shape(columns: list[int]) -> list[list[int | None]]: return cells +def grid_arrangement(n_grids: int) -> int: + """Number of columns to tile ``n_grids`` grid blocks into, near-square. + + ``ceil(sqrt(n))`` columns, so multiple grids are shown side by side in a + compact block instead of one tall vertical stack — e.g. 6 grids lay out as + 3 columns × 2 rows, 4 as 2 × 2. Always at least 1. + """ + if n_grids <= 1: + return 1 + return ceil(sqrt(n_grids)) + + def _dedupe_in_range(columns: list[int], n_channels: int) -> list[int]: """First-seen columns within ``[0, n_channels)``, deduplicated, order preserved.""" seen: set[int] = set() diff --git a/myogestic/widgets/signals/_controls.py b/myogestic/widgets/signals/_controls.py index 84ae852b..b1c51106 100644 --- a/myogestic/widgets/signals/_controls.py +++ b/myogestic/widgets/signals/_controls.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field +from math import ceil from typing import TYPE_CHECKING from imgui_bundle import icons_fontawesome_6 as fa @@ -8,11 +9,13 @@ from myogestic.widgets.common import PALETTE, pop_selected, push_selected, segmented from myogestic.widgets.signals._channel_grid import ( + grid_arrangement, normalize_layout, rect_to_channels, reduce_selection, ) from myogestic.widgets.signals._scan import _scan_panel +from myogestic.widgets.signals._state import _DETAIL_FULL, _DETAIL_MIN if TYPE_CHECKING: from myogestic.core import Context @@ -155,46 +158,51 @@ def render_filter_and_scale(stream_name: str, v: ViewerState, fs: float) -> None # Row break: the y-scaling group drops to its own line below source / # transport / View, instead of crowding them all onto one row. - # Y-scaling group kept together: Auto/Manual + Rescale + Per-Ch. When Per-Ch - # normalizes each channel into its own unit-height lane, the shared scale - # (Auto/Manual/Rescale, manual bounds, and Gain) is inert, so those controls - # grey out to make the active mode unambiguous. + # Y-scaling group kept together: Auto/Manual + Rescale + Per-Ch. `Per-Ch` is the scaling BASIS + # (shared axis vs one lane per channel); `Auto`/`Manual` is the adaptation policy and applies to + # either basis. Only the shared numeric min/max fields (and Gain in per-channel Auto, where + # normalization cancels it) are context-specific. per_ch = v.per_channel_scale if v.scale_mode not in ("auto", "manual"): v.scale_mode = "auto" - if per_ch: - imgui.begin_disabled() - # Segmented Auto/Manual instead of a cycle button — both modes visible, the - # active one raised. + # Per-Ch is the scaling BASIS; Auto/Manual decides whether that basis adapts — so both apply in + # per-channel mode too (they used to grey out). Segmented so both modes are visible. scale_i = segmented(f"{stream_name}_scale", ["Auto", "Manual"], 1 if v.scale_mode == "manual" else 0) v.scale_mode = "manual" if scale_i == 1 else "auto" if imgui.is_item_hovered(): - imgui.set_tooltip("Y-axis scale: Auto = per-frame fit · Manual = fixed y_min/y_max.") + imgui.set_tooltip( + "Y-axis scale — Auto: eases to the signal range (~5 s). Manual: holds it.\n" + "Per-Ch on: applied to each channel's own lane instead of one shared range." + ) - # One-shot "rescale now" — captures the current visible window's - # y-range into Manual mode. Useful when the user wants to lock in a - # good range once and then leave it alone (cheaper visually than - # Auto and steadier than continuous auto-fit). + # One-shot "Fit & lock": fit the current visible data (per basis) and switch to Manual, so it + # stays put. Remember the basis on the click so a same-frame Per-Ch toggle can't misapply it. imgui.same_line() if imgui.button(f"Rescale##{stream_name}_rescale"): - v.rescale_pending = True + v.rescale_pending = "per_channel" if per_ch else "shared" if imgui.is_item_hovered(): imgui.set_tooltip( - "Capture the current y-range into Manual mode.\n" - "Click again any time the trace goes off-scale." + ("Fit each channel to its own visible range" if per_ch else "Fit one shared range") + + " and lock to Manual.\nClick again any time the trace goes off-scale." ) - if per_ch: - imgui.end_disabled() imgui.same_line() ch_pc, pc = imgui.checkbox(f"Per-Ch##{stream_name}_perch", v.per_channel_scale) if ch_pc: v.per_channel_scale = pc if imgui.is_item_hovered(): - imgui.set_tooltip("Normalize each enabled channel into its own unit-height lane.") + imgui.set_tooltip( + "Normalize each enabled channel into its own lane.\n" + "Trace heights are then NOT comparable between channels." + ) - if per_ch or v.scale_mode != "manual": + if per_ch: + if v.scale_mode == "manual": # no 16-field editor — just a small locked-count status + imgui.same_line() + imgui.text_disabled(f"{len(v.pc_ranges)} locked") + return + if v.scale_mode != "manual": return imgui.same_line() @@ -219,21 +227,30 @@ def render_resolution_controls( # and the widths track the panel width / DPI instead of hand-computed pixels. max_window = stream._buffer_seconds if hasattr(stream, "_buffer_seconds") else 60.0 per_ch = v.per_channel_scale - if not imgui.begin_table(f"{stream_name}_scope_row", 3, imgui.TableFlags_.sizing_stretch_same): + if not imgui.begin_table(f"{stream_name}_scope_row", 4, imgui.TableFlags_.sizing_stretch_same): return imgui.table_next_row() imgui.table_next_column() - imgui.text("Point cap") + imgui.text("Detail") imgui.same_line() imgui.set_next_item_width(-1) - changed_r, new_r = imgui.slider_int(f"##{stream_name}_res", v.n_pixels, 100, 10000, "%d pts") + # `detail_factor` is points-per-pixel internally; show it to the user as a + # percentage of full detail (100% = the crispest _DETAIL_FULL density), so + # the control reads as "how much detail" and tops out at 100% rather than a + # confusing ">1x". + pct = v.detail_factor / _DETAIL_FULL * 100.0 + changed_r, new_pct = imgui.slider_float( + f"##{stream_name}_detail", pct, _DETAIL_MIN / _DETAIL_FULL * 100.0, 100.0, "%.0f%%" + ) if changed_r: - v.n_pixels = new_r + v.detail_factor = new_pct / 100.0 * _DETAIL_FULL if imgui.is_item_hovered(): imgui.set_tooltip( - "Ceiling on the points drawn per channel — the plot-width target is\n" - "capped here. Lower = coarser MinMax decimation, cheaper to draw." + "Display density only — recording and analysis are unchanged.\n" + "100% draws a few points per pixel (crispest); drag left for a\n" + "coarser, cheaper trace when many channels tax the frame rate.\n" + "MinMax keeps peak height, but fine shape and timing coarsen." ) imgui.table_next_column() @@ -247,7 +264,10 @@ def render_resolution_controls( imgui.table_next_column() imgui.text("Gain") imgui.same_line() - if per_ch: + # Gain is inert in per-channel AUTO (normalization cancels it), so grey it there; in per-channel + # MANUAL it magnifies each trace against its frozen range, so keep it live. + gain_inert = per_ch and v.scale_mode == "auto" + if gain_inert: imgui.begin_disabled() imgui.set_next_item_width(-1) changed_g, new_g = imgui.slider_float( @@ -255,9 +275,23 @@ def render_resolution_controls( ) if changed_g: v.gain = new_g - if per_ch: + if gain_inert: imgui.end_disabled() + imgui.table_next_column() + imgui.text("Reject <") + imgui.same_line() + imgui.set_next_item_width(-1) + changed_t, new_t = imgui.slider_float(f"##{stream_name}_transient", v.transient_ms, 0.0, 40.0, "%.0f ms") + if changed_t: + v.transient_ms = new_t + if imgui.is_item_hovered(): + imgui.set_tooltip( + "Ignore transients shorter than this when fitting the y-scale, so a brief movement\n" + "artifact doesn't blow up the range. 0 = plain min/max. Keep it below your shortest\n" + "real contraction." + ) + imgui.end_table() @@ -418,18 +452,30 @@ def render_grid_window( imgui.set_next_window_pos( imgui.ImVec2(mv.pos.x + 100.0, mv.pos.y + 100.0), imgui.Cond_.first_use_ever ) - imgui.set_next_window_size(imgui.ImVec2(520.0, 420.0), imgui.Cond_.first_use_ever) + cell = _grid_cell_size() + n_cols = grid_arrangement(len(layout)) + imgui.set_next_window_size( + _grid_window_size(layout, cell, n_cols), imgui.Cond_.first_use_ever + ) visible, still_open = imgui.begin( f"Channel selection — {stream_name}##{stream_name}_grid_window", True ) hovered_ch = -1 try: if visible: - cell = _grid_cell_size() + # Tile the grids near-square (n_cols per row) instead of one tall + # vertical stack: each grid is a `begin_group`ed block so `same_line` + # places the next column to its right, wrapping onto a new row every + # n_cols. `render_grid` reads its own absolute cursor origin, so its + # drag hit-testing keeps working wherever the block lands. for grid_idx, grid in enumerate(layout): + if grid_idx % n_cols != 0: + imgui.same_line(0.0, cell) # one-cell gap between grid columns + imgui.begin_group() hovered_ch = render_grid( stream_name, grid_idx, grid, enabled, ch_names, ui, cell, hovered_ch ) + imgui.end_group() finally: imgui.end() @@ -448,6 +494,30 @@ def _grid_cell_size() -> float: return imgui.get_frame_height() * 1.6 +def _grid_window_size(layout: list[ChannelGrid], cell: float, n_cols: int) -> imgui.ImVec2: + """First-open size for the grid window, fit to the tiled grid blocks. + + Sized so an ``n_cols``-wide near-square tiling of the grids opens fully + visible (capped so it never exceeds a reasonable on-screen size); the + window stays freely resizable afterwards. + """ + n = len(layout) + if n == 0: + return imgui.ImVec2(520.0, 420.0) + n_rows = ceil(n / n_cols) + style = imgui.get_style() + step_x = cell + style.item_spacing.x + step_y = cell + style.item_spacing.y + header = imgui.get_frame_height() * 1.4 # per-grid label + All/None row + w_cells = max((len(g.cells[0]) for g in layout if g.cells and g.cells[0]), default=1) + h_cells = max((len(g.cells) for g in layout if g.cells), default=1) + grid_w = w_cells * step_x + grid_h = h_cells * step_y + header + win_w = n_cols * grid_w + (n_cols - 1) * cell + style.window_padding.x * 2 + 20.0 + win_h = n_rows * grid_h + (n_rows - 1) * step_y + style.window_padding.y * 2 + 20.0 + return imgui.ImVec2(min(win_w, 1500.0), min(win_h, 950.0)) + + def render_grid( stream_name: str, grid_idx: int, diff --git a/myogestic/widgets/signals/_plot.py b/myogestic/widgets/signals/_plot.py index 5296c4db..d708e473 100644 --- a/myogestic/widgets/signals/_plot.py +++ b/myogestic/widgets/signals/_plot.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math import time as _time from typing import TYPE_CHECKING @@ -32,9 +33,17 @@ def render_plot( # Scale off the trace that is actually drawn (`frame.data`), not the raw # window — so an RMS envelope fills its lane instead of being dwarfed by # the raw amplitude, and warm-up/dropout NaNs are ignored. + # Ease the auto y-range toward the data BEFORE deriving the lane height / axis limits from it + # (no-op unless auto mode is active). `frame.frame_start` is this frame's perf_counter. + update_auto_scale(v, frame.data, frame.channel_map, stream_name, frame.frame_start) channel_height = resolve_channel_height(frame.data, channel_height, v) if v.per_channel_scale: - channel_ranges = resolve_channel_ranges(frame.data, frame.channel_map) + # Ease each lane's normalisation range (grow-fast/shrink-slow) so amplitudes don't breathe. + channel_ranges = update_per_channel_ranges( + v, frame.data, frame.channel_map, stream_name, frame.frame_start + ) + else: + v.pc_ease_t = 0.0 # snap per-channel ranges when it's next re-enabled ensure_specs(v, frame.n_channels) plot_w, plot_h = size @@ -105,16 +114,14 @@ def resolve_channel_height( # In per-channel mode every channel renormalises into a unit-height # lane, so the absolute amplitude no longer drives the layout. return 1.0 - if v is not None and v.scale_mode == "manual": - # Manual mode: pin channel height to the user's range so the - # lane spacing is perfectly stable, instead of recomputing it - # from the visible data each frame (which causes per-frame - # min/max ticks to wobble the channels vertically inside the - # fixed axis). + if v is not None: + # Manual AND (eased) auto both pin the lane height to `y_min/y_max` so the + # spacing is perfectly stable, instead of recomputing it from the visible + # data each frame (which wobbled the channels vertically). In auto, + # `update_auto_scale` has already eased `y_min/y_max` toward the data range. span = float(v.y_max - v.y_min) return span if span > 0 else 1.0 - # Auto mode: derive from the visible trace so stale spikes in the raw - # ring cannot flatten the live trace. + # No ViewerState (bare preview): derive from the visible trace. if plotted.size == 0: return 1.0 finite = plotted[np.isfinite(plotted)] @@ -131,19 +138,235 @@ def resolve_channel_ranges( """Per-channel `(min, max)` of the drawn trace, keyed by real channel index. `plotted[:, col]` is channel `channel_map[col]` (the enabled-only compaction - used everywhere the plot draws); non-finite values are dropped. + used everywhere the plot draws); non-finite values are dropped, and an all-non-finite + column is omitted. + + Vectorized (axis-0 reductions, not a Python per-column loop) — this runs every frame in + per-channel mode, where the loop cost dominated the frame at high channel counts. """ ranges: dict[int, tuple[float, float]] = {} if plotted.size == 0: return ranges + finite = np.isfinite(plotted) + if finite.all(): # common live case: no copy, plain axis-0 min/max + lo = plotted.min(axis=0) + hi = plotted.max(axis=0) + else: # drop non-finite per element, then nan-aware reduce (all-nan column ⇒ nan ⇒ skipped) + masked = np.where(finite, plotted, np.nan) + with np.errstate(invalid="ignore"): + lo = np.nanmin(masked, axis=0) + hi = np.nanmax(masked, axis=0) + ok = np.isfinite(lo) for col, ch in enumerate(channel_map): - finite = plotted[:, col][np.isfinite(plotted[:, col])] - if finite.size == 0: - continue - ranges[ch] = (float(finite.min()), float(finite.max())) + if ok[col]: + ranges[ch] = (float(lo[col]), float(hi[col])) return ranges +#: Time-bin width (ms) for the duration-based robust range. Each bin contributes one min and one +#: max; a transient that fills fewer than the "ignore transients shorter than" budget of these bins +#: is dropped as an artifact. +_ROBUST_BIN_MS = 5.0 + + +def robust_channel_ranges( + data: np.ndarray, + channel_map: list[int], + transient_ms: float, + window_s: float, + display_filter: str, + rms_window_ms: float, + rms_hop_ms: float, +) -> dict[int, tuple[float, float]]: + """Per-channel range that ignores transients shorter than ``transient_ms`` (movement artifacts). + + *Duration*, not amplitude, separates an artifact (a brief spike) from a contraction (a sustained + burst) — they look alike by amplitude. The visible window is split into equal-time bins; each + bin contributes one min and one max; the ``k`` most extreme bin maxima/minima (``k`` covering + the transient budget) are dropped and the next extrema are the robust range. A 10 ms artifact + fills ~2 bins (dropped); a 50 ms contraction fills ~11 (kept). Mode-aware: ``rectify`` / + ``rms_env`` are one-sided (lower bound pinned to 0, only the top trimmed), and ``rms_env``'s + envelope is already time-binned, so its own points are the bins with the RMS smear folded into + ``k``. ``transient_ms == 0`` degrades to plain per-channel min/max. + + Cheap enough for the per-frame path: reshape + axis-1 min/max + one axis-0 partition (no + full-window percentile). Falls back gracefully to min/max when there are too few bins. + """ + n = len(data) + if n == 0 or not channel_map: + return {} + one_sided = display_filter in ("rectify", "rms_env") + if display_filter == "rms_env": + highs = np.asarray(data) # each envelope point ≈ one time bin (keep native dtype) + lows = highs + k = int(np.ceil((transient_ms + rms_window_ms) / max(rms_hop_ms, 1e-6))) + else: + bin_n = max(1, round(n * _ROBUST_BIN_MS / (window_s * 1000.0))) if window_s > 0 else 1 + n_bins = max(1, n // bin_n) + head = np.asarray(data[: n_bins * bin_n]).reshape(n_bins, bin_n, -1) # view, no copy/cast + if np.isfinite(head).all(): # common live case: plain (fast) min/max, no nan bookkeeping + highs = head.max(axis=1) # (n_bins, n_ch) + lows = head.min(axis=1) + else: + with np.errstate(invalid="ignore"): + highs = np.nanmax(head, axis=1) + lows = np.nanmin(head, axis=1) + bin_ms = window_s * 1000.0 / n_bins if window_s > 0 else _ROBUST_BIN_MS + k = int(np.ceil(transient_ms / max(bin_ms, 1e-6))) + (1 if transient_ms > 0 else 0) + # A NaN bin must not corrupt the order statistic: park it at the non-selected end. + highs = np.where(np.isfinite(highs), highs, -np.inf) + lows = np.where(np.isfinite(lows), lows, np.inf) + n_bins = highs.shape[0] + k = max(0, min(k, (n_bins - 1) // 2)) # always keep ≥1 bin per side (→ min/max when few bins) + hi_idx, lo_idx = n_bins - 1 - k, k + hi = np.partition(highs, hi_idx, axis=0)[hi_idx] # (k+1)-th largest bin-max per channel + lo = np.zeros_like(hi) if one_sided else np.partition(lows, lo_idx, axis=0)[lo_idx] + ranges: dict[int, tuple[float, float]] = {} + for col, ch in enumerate(channel_map): + if np.isfinite(hi[col]) and np.isfinite(lo[col]): + ranges[ch] = (float(lo[col]), float(hi[col])) + return ranges + + +#: Auto-scale CONTRACTION settle time (s) — how long to shrink the range when the signal +#: quietens (~95% at 3·tau). Slow, so the range never jitters downward. Expansion is INSTANT +#: (the bound snaps out to contain a new peak), so nothing is ever clipped while it catches up. +_SCALE_EASE_S = 5.0 + + +def update_auto_scale( + v: ViewerState, + data: np.ndarray, + channel_map: list[int], + stream_name: str, + now: float, +) -> None: + """Ease `v.y_min`/`v.y_max` toward the drawn data's padded range (auto mode only). + + Replaces ImPlot's per-frame `auto_fit` refit — which makes a variable signal zoom in/out + constantly — with a **grow-fast / shrink-slow** ease toward the drawn window's gain-scaled + global min/max: the range EXPANDS quickly (``_SCALE_EXPAND_S``) so a new peak/contraction is + never clipped, and CONTRACTS slowly (``_SCALE_EASE_S``) so it never jitters downward. + **Snaps** instead of easing on the first frame + and whenever the context ``(active stream, channels, display filter, notch, gain)`` changes, + so it never eases across an unrelated scale; a huge ``dt`` (e.g. after a pause) also snaps. + No-op unless auto mode is active and per-channel scaling is off. + """ + if v.scale_mode != "auto" or v.per_channel_scale: + # Force a snap when auto resumes, so re-entering auto doesn't ease from stale/manual bounds. + v.scale_ease_t = 0.0 + return + # Artifact-robust range per channel, then take the UNION for the shared axis — never a global + # scan (a contraction on one of many channels would be statistically drowned out). Gain-correct + # (`plot_channel` draws `data * v.gain`). + g = v.gain + robust = robust_channel_ranges( + data, channel_map, v.transient_ms, v.window, v.display_filter, v.rms_window_ms, v.rms_hop_ms + ) + if not robust: + return # empty / all-NaN window: hold the current range + lo = min(r[0] for r in robust.values()) * g + hi = max(r[1] for r in robust.values()) * g + if lo > hi: + lo, hi = hi, lo + span = hi - lo + pad = span * 0.1 if span > 0 else 1.0 + target_lo, target_hi = lo - pad, hi + pad + + # Snap (don't ease) on the first frame or any change that alters the scale: which stream a + # selectable viewer shows (`selected_stream`, not the stable widget id), the channel set, the + # display filter, the mains notch, or the gain. + key = ( + v.selected_stream or stream_name, + tuple(channel_map), + v.display_filter, + v.mains_notch, + g, + v.rms_window_ms, + v.rms_hop_ms, + v.transient_ms, + ) + if key != v.scale_ease_key or v.scale_ease_t <= 0.0: + v.y_min, v.y_max = target_lo, target_hi # snap: first frame / context change + else: + dt = max(0.0, now - v.scale_ease_t) # backward clock ⇒ dt 0 ⇒ no move + a = 1.0 - math.exp(-dt / (_SCALE_EASE_S / 3.0)) # contraction ease + # Grow INSTANTLY (snap the bound out so a new peak never clips), shrink slowly (no jitter). + v.y_max = target_hi if target_hi > v.y_max else v.y_max + a * (target_hi - v.y_max) + v.y_min = target_lo if target_lo < v.y_min else v.y_min + a * (target_lo - v.y_min) + v.scale_ease_key = key + v.scale_ease_t = now + + +def update_per_channel_ranges( + v: ViewerState, + data: np.ndarray, + channel_map: list[int], + stream_name: str, + now: float, +) -> dict[int, tuple[float, float]]: + """Per-channel normalisation ranges (GAINED) for per-channel mode, keyed by real channel. + + `per_channel_scale` is the *basis*; `scale_mode` decides whether it adapts: + + - **Auto**: ease each channel's range grow-fast (snap out so a louder channel never overflows + its lane) / shrink-slow (no per-frame breathing), snapping on the first frame / a + newly-enabled channel / a context change (stream / channels / filter / notch / gain / rms). + - **Manual**: hold the frozen ranges untouched — so a channel weakening, strengthening, or + drifting stays visible against its captured reference. Only a *newly-enabled* channel (with + no saved range) is initialised from its current data; existing ones never move. + + Ranges are stored **gained** (× `v.gain`): in Auto gain is inert (it cancels in `plot_channel`), + but in Manual, changing gain magnifies the trace against the frozen reference. Returns the dict + and stores it on `v.pc_ranges` (channels no longer drawn drop out). + """ + g = v.gain + args = (v.transient_ms, v.window, v.display_filter, v.rms_window_ms, v.rms_hop_ms) + if v.scale_mode != "auto": # MANUAL: hold frozen ranges; init only a newly-enabled channel + need_init = any(ch not in v.pc_ranges for ch in channel_map) + raw = robust_channel_ranges(data, channel_map, *args) if need_init else {} + held: dict[int, tuple[float, float]] = {} + for ch in channel_map: + if ch in v.pc_ranges: + held[ch] = v.pc_ranges[ch] + elif ch in raw: + lo, hi = raw[ch] + held[ch] = (lo * g, hi * g) + v.pc_ranges = held + v.pc_ease_t = 0.0 # snap once when Auto resumes + return held + + targets = robust_channel_ranges(data, channel_map, *args) + key = ( + v.selected_stream or stream_name, + tuple(channel_map), + v.display_filter, + v.mains_notch, + g, + v.rms_window_ms, + v.rms_hop_ms, + v.transient_ms, + ) + snap = key != v.pc_ease_key or v.pc_ease_t <= 0.0 + dt = max(0.0, now - v.pc_ease_t) + a = 1.0 - math.exp(-dt / (_SCALE_EASE_S / 3.0)) # contraction ease; expansion snaps instantly + eased: dict[int, tuple[float, float]] = {} + for ch, (r_lo, r_hi) in targets.items(): + t_lo, t_hi = r_lo * g, r_hi * g # gained target + prev = v.pc_ranges.get(ch) + if snap or prev is None: # first frame / context change / newly-enabled channel + eased[ch] = (t_lo, t_hi) + else: + lo, hi = prev + hi = t_hi if t_hi > hi else hi + a * (t_hi - hi) # grow instantly, shrink slow + lo = t_lo if t_lo < lo else lo + a * (t_lo - lo) + eased[ch] = (lo, hi) + v.pc_ranges = eased + v.pc_ease_key = key + v.pc_ease_t = now + return eased + + def ensure_specs(v: ViewerState, n_channels: int) -> None: if len(v.specs) >= n_channels: return @@ -171,9 +394,22 @@ def setup_axes( implot.Cond_.always, # type: ignore[attr-defined] ) - if v.scale_mode == "auto": - implot.setup_axis(implot.ImAxis_.y1, flags=implot.AxisFlags_.auto_fit) + if v.per_channel_scale: + # Pin to the fixed unit-lane geometry (each lane fills ±0.4 around baselines stacked by + # `channel_height` == 1.0). NOT `auto_fit`: while a channel's eased range contracts its + # trace occupies less than its lane, and auto_fit would zoom to that and cancel the ease. + implot.setup_axis(implot.ImAxis_.y1) + n_enabled = max(1, len(enabled)) + implot.setup_axis_limits( + implot.ImAxis_.y1, + -0.5 - (n_enabled - 1) * channel_height, + 0.5, + implot.Cond_.always, # type: ignore[attr-defined] + ) else: + # Auto AND manual apply the same fixed limits every frame; only the values differ — + # auto's `y_min/y_max` are eased toward the data by `update_auto_scale`, manual's are held. + # Pinning them each frame (instead of `auto_fit`) is what stops the per-frame zoom jitter. implot.setup_axis(implot.ImAxis_.y1) y_min, y_max = v.y_min, v.y_max n_enabled = max(1, len(enabled)) @@ -230,7 +466,9 @@ def plot_channel( offset = -col_idx * channel_height if v.per_channel_scale: - ch_data = np.asarray(col_ch, dtype=np.float64) + # `ch_data` and the ranges are both GAINED. In Auto gain cancels here (range is eased in the + # same gained units); in Manual the range is frozen, so gain magnifies against it. + ch_data = np.asarray(col_ch, dtype=np.float64) * v.gain if channel_ranges is not None and ch in channel_ranges: ch_min, ch_max = channel_ranges[ch] elif ch_data.size: diff --git a/myogestic/widgets/signals/_state.py b/myogestic/widgets/signals/_state.py index 977b019e..d6087e44 100644 --- a/myogestic/widgets/signals/_state.py +++ b/myogestic/widgets/signals/_state.py @@ -130,7 +130,8 @@ def notched( class ViewerState: """Per-widget-id viewer state.""" - n_pixels: int = 2000 + n_pixels: int | None = None # optional hard cap on drawn points; None/0 = no cap + detail_factor: float = 3.0 # draw density (points per plot pixel); the "Detail" slider drives it window: float = 1.0 gain: float = 1.0 channels: set[int] = field(default_factory=set) @@ -153,7 +154,26 @@ class ViewerState: y_min: float = -1.0 y_max: float = 1.0 per_channel_scale: bool = False - rescale_pending: bool = False + # Pending "Rescale/Fit & lock" click, remembering which BASIS it applies to ("shared" fits the + # one shared y-range; "per_channel" fits each channel's lane) so a same-frame Per-Ch toggle + # can't misapply it. `None` = no request. Consumed once in `viewer.py`. + rescale_pending: str | None = None + # Auto-scale easing state (see `_plot.update_auto_scale`): in auto mode `y_min/y_max` are + # eased toward the data range each frame instead of ImPlot refitting instantly. `scale_ease_t` + # is the last frame's `perf_counter` (dt source); `scale_ease_key` is the (stream, channels, + # display_filter) context — a change snaps rather than eases across an unrelated scale. + scale_ease_key: tuple | None = None + scale_ease_t: float = 0.0 + # Per-channel-mode easing (see `_plot.update_per_channel_ranges`): the same grow-fast/ + # shrink-slow ease, but over each channel's normalisation `(min, max)` so unit-lane amplitudes + # don't breathe every frame. `pc_ranges` is the eased range per real channel index. + pc_ranges: dict[int, tuple[float, float]] = field(default_factory=dict, repr=False) + pc_ease_key: tuple | None = None + pc_ease_t: float = 0.0 + # Artifact-robust scaling: ignore transients shorter than this (ms) when fitting the y-range, + # so a brief movement-artifact spike doesn't define the scale. 0 = plain min/max. See + # `_plot.robust_channel_ranges`. + transient_ms: float = 20.0 paused: bool = False frozen_ts: np.ndarray | None = None frozen_data: np.ndarray | None = None @@ -170,6 +190,7 @@ class ViewerState: rms_hop_ms: float = 20.0 show_markers: bool = True show_retarget: bool = False + show_controls: bool = True # top control menu; toggled from the panel header for plot/grid room # Decimation output-size target used by the *last* `render_plot` call # (sized to the live plot's pixel width there — see # `resolve_decimation_target`). `render_footer` reads it back to report @@ -353,13 +374,17 @@ def minmax_grid_all_shared_x( return xs, ys -#: Oversampling factor applied to the plot's pixel width when sizing the -#: MinMax decimation target — a few points per pixel keeps sharp features -#: visible without materially increasing draw cost. -_DECIMATE_PIXEL_FACTOR = 3.0 +#: Draw-density bounds for the "Detail" control, in points per plot pixel. +#: `_DETAIL_FULL` (a few points/pixel) keeps sharp features / MinMax peaks +#: crisp; `_DETAIL_MIN` is the coarsest, cheapest trace. +_DETAIL_MIN = 0.5 +_DETAIL_FULL = 3.0 #: Floor on the width-derived target so a very narrow (or not-yet-laid-out) #: plot never collapses decimation down to near nothing. _DECIMATE_MIN_POINTS = 64 +#: Target used only on the first frame, before the plot has a real pixel +#: width and no explicit `n_pixels` cap is set. Self-corrects next frame. +_DECIMATE_FALLBACK_POINTS = 2000 def resolve_decimation_target(plot_width_px: float, v: ViewerState) -> int: @@ -367,34 +392,44 @@ def resolve_decimation_target(plot_width_px: float, v: ViewerState) -> int: `plot_width_px` should come from the live plot (e.g. ``implot.get_plot_size().x``, only valid between `begin_plot` / - `end_plot`). `v.n_pixels` (the "Resolution" control) is the ceiling on - the result — it also doubles as the fallback when `plot_width_px` isn't - available yet (e.g. the very first frame, reported as `<= 0`) — so the - control still has an effect once the plot has a real size, instead of - being the primary driver the way the old fixed `n_pixels * 4` budget - was. + `end_plot`). The drawn point count per channel is + ``plot_width_px * v.detail_factor`` — the "Detail" control sets the + density directly, so full detail always tracks the plot width instead of + fighting a fixed absolute cap. `v.n_pixels` is an *optional* hard cap + (an escape hatch, `None`/`0` = no cap) and the fallback when + `plot_width_px` isn't available yet (the very first frame, reported as + `<= 0`). """ - cap = max(4, int(v.n_pixels)) if plot_width_px <= 0: - target = cap + target = v.n_pixels or _DECIMATE_FALLBACK_POINTS else: - width_target = max(_DECIMATE_MIN_POINTS, int(plot_width_px * _DECIMATE_PIXEL_FACTOR)) - target = min(cap, width_target) + target = max(_DECIMATE_MIN_POINTS, int(plot_width_px * v.detail_factor)) + if v.n_pixels: + target = min(target, v.n_pixels) return max(4, (target // 4) * 4) def get_viewer_state( ctx: Context, - stream_name: str, - n_pixels: int, + widget_id: str, + n_pixels: int | None, scale_mode: str, y_range: tuple[float, float], show_markers: bool, window_s: float | None = None, + stream_name: str | None = None, + show_controls: bool = True, ) -> ViewerState: - v = _viewers.get(stream_name) + """Per-widget viewer state, created on first use. + + Keyed by ``widget_id`` — NOT by the stream — so several viewers can show + the same stream (e.g. one panel per electrode grid) without sharing one + another's channels, scale, pause or filter. ``stream_name`` defaults to + ``widget_id`` and is only used to pick the initial window from the stream. + """ + v = _viewers.get(widget_id) if v is None: - s0 = ctx.streams.get(stream_name) + s0 = ctx.streams.get(stream_name or widget_id) # Caller override wins; fall back to the stream's processing window # (typically tiny — 0.2 s for classification — which is fine for the # model but unreadable on screen). @@ -410,8 +445,9 @@ def get_viewer_state( y_min=y_range[0], y_max=y_range[1], show_markers=show_markers, + show_controls=show_controls, ) - _viewers[stream_name] = v + _viewers[widget_id] = v return v diff --git a/myogestic/widgets/signals/viewer.py b/myogestic/widgets/signals/viewer.py index 7cae56aa..fcaf3ac0 100644 --- a/myogestic/widgets/signals/viewer.py +++ b/myogestic/widgets/signals/viewer.py @@ -21,7 +21,7 @@ def my_ui(ctx): from imgui_bundle import icons_fontawesome_6 as fa from imgui_bundle import imgui -from myogestic.widgets.common import panel_header +from myogestic.widgets.common import panel_header_button from myogestic.widgets.signals._controls import ( render_channel_controls, render_controls, @@ -29,7 +29,7 @@ def my_ui(ctx): from myogestic.widgets.signals._plot import ( render_footer, render_plot, - resolve_channel_ranges, + robust_channel_ranges, ) from myogestic.widgets.signals._scan import _disconnected_ui from myogestic.widgets.signals._state import ( @@ -57,6 +57,25 @@ class SignalViewer: the user may switch the active stream from the UI — each stream's channel selection is tracked separately and restored when the user switches back. + widget_id + Explicit state / ImGui id scope. Defaults to ``stream_name``. Give each + viewer its OWN id to show one stream through several panels (e.g. one + per electrode grid) — otherwise they share a single state and render + identically. Pair with ``initial_channels`` to open each on its own + channels, and prefer a stable, unique string (grid labels can repeat). + title + Panel header text. Defaults to ``"SIGNAL · "`` — set it per + panel when several viewers share a stream, or every tile reads alike. + show_controls + Whether the panel's chrome — title, control menu, channel bar and + footer — starts expanded (default ``True``). The ``≡`` button toggles it + at runtime (collapsing to a bare icon, so there is always a way back); + pass ``False`` for small tiled panels that should be nearly all plot. + n_pixels + Optional hard cap on the points drawn per channel. ``None`` (default) + means no cap — draw density tracks the plot width via the runtime + "Detail" slider, which is the normal control. Set it only to force a + ceiling (e.g. a slow machine with very high channel counts). scale_mode ``"auto"`` for ImPlot fitting, ``"manual"`` for the user-set ``y_range``. @@ -87,7 +106,7 @@ def __init__( stream_name: str, *, size: tuple[float, float] = (-1, -1), - n_pixels: int = 2000, + n_pixels: int | None = None, channel_height: float = 0.0, show_diagnostics: bool = False, selectable: bool = False, @@ -96,8 +115,14 @@ def __init__( show_markers: bool = False, window_s: float = 5.0, initial_channels: Iterable[int] | None = None, + widget_id: str | None = None, + title: str | None = None, + show_controls: bool = True, ) -> None: self._stream_name = stream_name + self._widget_id = widget_id + self._title = title + self._show_controls = show_controls self._size = size self._n_pixels = n_pixels self._channel_height = channel_height @@ -112,19 +137,41 @@ def __init__( def ui(self, ctx: Context) -> None: """Render the viewer. Call once per frame inside ``@app.ui``.""" stream_name = self._stream_name + # Everything below keys off `wid` (state dict + every ImGui id), while the + # STREAM is looked up by `stream_name` — so N viewers can share one stream + # (a panel per electrode grid) without sharing each other's state. + wid = self._widget_id or stream_name v = get_viewer_state( ctx, - stream_name, + wid, n_pixels=self._n_pixels, scale_mode=self._scale_mode, y_range=self._y_range, show_markers=self._show_markers, window_s=self._window_s, + stream_name=stream_name, + show_controls=self._show_controls, ) active_stream = v.selected_stream or stream_name stream = ctx.streams.get(active_stream) - panel_header(f"SIGNAL · {active_stream}", fa.ICON_FA_CHART_LINE) + # The `≡` toggle collapses ALL the chrome — title, control menu, channel + # bar and footer — leaving just the plot, which is what a small tiled + # panel wants. Collapsed, it shrinks to the bare icon (rather than + # vanishing) so there is always a way back. + if v.show_controls: + toggled = panel_header_button( + self._title or f"SIGNAL · {active_stream}", + fa.ICON_FA_CHART_LINE, + fa.ICON_FA_BARS, + tooltip="Hide title, controls, channel bar and footer", + ) + else: + toggled = imgui.small_button(f"{fa.ICON_FA_BARS}##{wid}_show_chrome") + if imgui.is_item_hovered(): + imgui.set_tooltip("Show title, controls, channel bar and footer") + if toggled: + v.show_controls = not v.show_controls if stream is None: imgui.text(f"{active_stream}: not found") return @@ -132,7 +179,8 @@ def ui(self, ctx: Context) -> None: _disconnected_ui(active_stream, stream) return - render_controls(ctx, stream_name, active_stream, stream, v, self._selectable) + if v.show_controls: + render_controls(ctx, wid, active_stream, stream, v, self._selectable) # Resolve which channels are enabled from persistent state *before* # building the frame, so the frame's column slice and the plot loop's @@ -143,8 +191,11 @@ def ui(self, ctx: Context) -> None: # Channel bar at the top, above the plot. It reads/mutates `v.channels` # (the same set `enabled` points at) and reports grid-hover — all # applied this frame, so channel toggles and the hover highlight take - # effect immediately instead of a frame late. - _, _, hovered_ch = render_channel_controls(stream_name, stream, v, n_channels) + # effect immediately instead of a frame late. Hidden with the rest of the + # chrome; nothing can be hovered then, so the highlight resets to -1. + hovered_ch = -1 + if v.show_controls: + _, _, hovered_ch = render_channel_controls(wid, stream, v, n_channels) v.last_hovered = hovered_ch frame = build_signal_frame(stream, v, enabled) @@ -158,17 +209,32 @@ def ui(self, ctx: Context) -> None: # (`resolve_channel_ranges`), so none need to be precomputed here. channel_ranges = None - # Honour a "Rescale" button click from the controls bar: snap y_min / - # y_max to the range of what is actually drawn (the raw window, or the - # RMS envelope in rms_env mode), then switch to Manual so it stays put. + # Honour a "Rescale / Fit & lock" click: fit the current visible data and switch to Manual + # so it holds. The BASIS carried on `rescale_pending` decides what gets fit — one shared + # range, or each channel's own lane. Ranges are gained (`plot_channel` draws `data * gain`). if v.rescale_pending: - v.rescale_pending = False - ranges = resolve_channel_ranges(frame.data, frame.channel_map) - if ranges: - mins = [lo for lo, _ in ranges.values()] - maxs = [hi for _, hi in ranges.values()] - lo = min(mins) - hi = max(maxs) + basis = v.rescale_pending + v.rescale_pending = None + ranges = robust_channel_ranges( + frame.data, + frame.channel_map, + v.transient_ms, + v.window, + v.display_filter, + v.rms_window_ms, + v.rms_hop_ms, + ) + g = v.gain + if basis == "per_channel": + # Fit each enabled channel to its own visible range; replaces the frozen ranges. + v.pc_ranges = {ch: (lo * g, hi * g) for ch, (lo, hi) in ranges.items()} + v.scale_mode = "manual" + elif ranges: + # Fit one shared range across channels (the pre-existing behaviour). + lo = min(lo for lo, _ in ranges.values()) * g + hi = max(hi for _, hi in ranges.values()) * g + if lo > hi: + lo, hi = hi, lo span = hi - lo pad = span * 0.1 if span > 0 else 1.0 v.y_min = lo - pad @@ -178,7 +244,7 @@ def ui(self, ctx: Context) -> None: if enabled: render_plot( ctx=ctx, - stream_name=stream_name, + stream_name=wid, stream=stream, v=v, frame=frame, @@ -192,15 +258,16 @@ def ui(self, ctx: Context) -> None: else: imgui.text("No channels enabled") - render_footer( - stream_name=stream_name, - stream=stream, - v=v, - frame=frame, - enabled=enabled, - ch_names=ch_names, - show_diagnostics=self._show_diagnostics, - ) + if v.show_controls: + render_footer( + stream_name=wid, + stream=stream, + v=v, + frame=frame, + enabled=enabled, + ch_names=ch_names, + show_diagnostics=self._show_diagnostics, + ) __all__ = ["SignalViewer"] diff --git a/tests/test_auto_scale_ease.py b/tests/test_auto_scale_ease.py new file mode 100644 index 00000000..bf18be65 --- /dev/null +++ b/tests/test_auto_scale_ease.py @@ -0,0 +1,295 @@ +"""Tests for the eased auto y-scale (`_plot.update_auto_scale`). + +The signal viewer's auto mode used to hand the y-axis to ImPlot's per-frame `auto_fit`, so a +variable signal zoomed in/out constantly. Now `update_auto_scale` eases `y_min/y_max` toward the +drawn data's padded range over ~5 s. These pin the pure state update: it eases gradually and +settles, snaps on a context change / first frame / huge dt, and is a no-op in manual / per-channel +modes — all without a live plot. +""" + +from __future__ import annotations + +import numpy as np + +from myogestic.widgets.signals._plot import ( + _SCALE_EASE_S, + robust_channel_ranges, + update_auto_scale, + update_per_channel_ranges, +) +from myogestic.widgets.signals._state import ViewerState + + +def _data(lo, hi, n=200, n_ch=2): + """Trace whose every column spans exactly ``[lo, hi]`` → target range ``[lo-pad, hi+pad]``.""" + col = np.linspace(lo, hi, n, dtype=np.float32) + return np.stack([col] * n_ch, axis=1) + + +def _target(lo, hi): + span = hi - lo + pad = span * 0.1 if span > 0 else 1.0 + return lo - pad, hi + pad + + +def _data_cols(spans, n=200): + """Trace with a given ``(lo, hi)`` per column → per-channel ranges keyed by column index.""" + cols = [np.linspace(lo, hi, n, dtype=np.float32) for lo, hi in spans] + return np.stack(cols, axis=1) + + +def _v(**kw): + """ViewerState with transient rejection OFF, so the ease-logic tests use exact min/max.""" + v = ViewerState(**kw) + v.transient_ms = 0.0 + return v + + +def test_first_frame_snaps(): + v = _v() # scale_mode defaults to "auto" + update_auto_scale(v, _data(-0.1, 0.1), [0, 1], "emg", now=100.0) + lo0, hi0 = _target(-0.1, 0.1) + assert np.isclose(v.y_min, lo0) and np.isclose(v.y_max, hi0) # first frame snaps to the range + + +def test_expands_instantly_so_a_new_peak_is_never_clipped(): + v = _v() + update_auto_scale(v, _data(-0.1, 0.1), [0, 1], "emg", now=100.0) # snap small + lo, hi = _target(-2.0, 2.0) # a contraction appears + update_auto_scale(v, _data(-2.0, 2.0), [0, 1], "emg", now=100.016) # ONE frame + assert v.y_max >= hi - 1e-9 and v.y_min <= lo + 1e-9 # bound snapped OUT to contain it, no clip + + +def test_contracts_slowly_then_settles(): + v = _v() + update_auto_scale(v, _data(-2.0, 2.0), [0, 1], "emg", now=100.0) # snap large + big = v.y_max + update_auto_scale(v, _data(-0.1, 0.1), [0, 1], "emg", now=100.016) # signal quietens, 1 frame + assert v.y_max > 0.9 * big # barely moved down after one frame — shrink-slow, no jitter + + t = 100.016 + for _ in range(int(round(_SCALE_EASE_S / 0.016)) + 5): # ~5 s + t += 0.016 + update_auto_scale(v, _data(-0.1, 0.1), [0, 1], "emg", now=t) + assert abs(v.y_max - _target(-0.1, 0.1)[1]) < 0.15 # settled near the small target + + +def test_context_change_snaps_immediately(): + v = _v() + update_auto_scale(v, _data(-0.1, 0.1), [0, 1], "emg", now=100.0) + update_auto_scale(v, _data(-0.1, 0.1), [0, 1], "emg", now=105.0) # settled small + + # A different channel set is a new context → snap to the new (large) range, no ease. + update_auto_scale(v, _data(-3.0, 3.0), [0, 2], "emg", now=105.016) + lo, hi = _target(-3.0, 3.0) + assert np.isclose(v.y_min, lo) and np.isclose(v.y_max, hi) + + +def test_manual_and_per_channel_leave_limits_untouched(): + v = _v() + v.y_min, v.y_max = -1.0, 1.0 + v.scale_mode = "manual" + update_auto_scale(v, _data(-5.0, 5.0), [0, 1], "emg", now=100.0) + assert (v.y_min, v.y_max) == (-1.0, 1.0) # manual: held + + v.scale_mode = "auto" + v.per_channel_scale = True + update_auto_scale(v, _data(-5.0, 5.0), [0, 1], "emg", now=100.0) + assert (v.y_min, v.y_max) == (-1.0, 1.0) # per-channel: unit lanes, not this axis + + +def test_expansion_ignores_dt_but_contraction_backward_clock_is_a_noop(): + v = _v() + update_auto_scale(v, _data(-0.1, 0.1), [0, 1], "emg", now=100.0) # snap small (first frame) + # Expansion is instant regardless of dt (snap out) — even a tiny/huge dt fully contains it. + update_auto_scale(v, _data(-2.0, 2.0), [0, 1], "emg", now=100.001) + assert abs(v.y_max - _target(-2.0, 2.0)[1]) < 1e-9 # ±2.4, snapped + + # A CONTRACTING target with a backwards clock must not move (contraction dt clamps to 0). + before = (v.y_min, v.y_max) + update_auto_scale(v, _data(-0.5, 0.5), [0, 1], "emg", now=99.0) # smaller + clock backwards + assert (v.y_min, v.y_max) == before + + +def test_gain_scales_the_target(): + # plot_channel draws data*gain, so the eased range must be gain-scaled too, else it clips. + v = _v() + v.gain = 10.0 + update_auto_scale(v, _data(-0.1, 0.1), [0, 1], "emg", now=100.0) # first frame snaps + lo, hi = _target(-1.0, 1.0) # gained range ±1.0 → padded ±1.2 + assert np.isclose(v.y_max, hi) and np.isclose(v.y_min, lo) + + +def test_reentering_auto_after_manual_snaps(): + v = _v() + update_auto_scale(v, _data(-0.1, 0.1), [0, 1], "emg", now=100.0) # auto snap small + update_auto_scale(v, _data(-0.1, 0.1), [0, 1], "emg", now=105.0) # settled + + v.scale_mode = "manual" + update_auto_scale(v, _data(-2.0, 2.0), [0, 1], "emg", now=105.1) # manual: no-op, reset ease + assert v.scale_ease_t == 0.0 + + v.scale_mode = "auto" + update_auto_scale(v, _data(-2.0, 2.0), [0, 1], "emg", now=110.0) # re-enter auto → snap + lo, hi = _target(-2.0, 2.0) + assert np.isclose(v.y_max, hi) and np.isclose(v.y_min, lo) # snapped, not eased from stale + + +def test_rms_window_change_snaps(): + # Changing the RMS window rebuilds a materially different envelope → snap, don't slow-contract + # across it. rms_env ranges are one-sided/robust, so assert the SNAP happened, not exact values. + v = _v() + v.display_filter = "rms_env" + update_auto_scale(v, _data(-3.0, 3.0), [0, 1], "emg", now=100.0) # snap large + update_auto_scale(v, _data(-3.0, 3.0), [0, 1], "emg", now=105.0) # settled large + assert v.y_max > 2.0 + v.rms_window_ms = 250.0 + update_auto_scale(v, _data(-0.1, 0.1), [0, 1], "emg", now=105.016) # much smaller data + assert v.y_max < 0.5 # snapped down in one frame (a slow contract would still be ~3) + + +# --- per-channel mode: each lane's normalisation range is eased the same way ---------------- + + +def _pc(per_channel=True): + v = _v() + v.per_channel_scale = per_channel + return v + + +def test_per_channel_first_frame_snaps_to_raw_ranges(): + v = _pc() + r = update_per_channel_ranges(v, _data_cols([(-1.0, 1.0), (-0.2, 0.5)]), [0, 1], "emg", now=100.0) + assert np.allclose(r[0], (-1.0, 1.0)) and np.allclose(r[1], (-0.2, 0.5)) # raw min/max, snapped + + +def test_per_channel_expands_instantly_so_a_louder_channel_never_overflows(): + v = _pc() + update_per_channel_ranges(v, _data_cols([(-0.1, 0.1)]), [0], "emg", now=100.0) # snap small + r = update_per_channel_ranges(v, _data_cols([(-2.0, 2.0)]), [0], "emg", now=100.016) # ONE frame + assert r[0][1] >= 2.0 - 1e-9 and r[0][0] <= -2.0 + 1e-9 # range snapped OUT in one frame + + +def test_per_channel_contracts_slowly_then_settles(): + v = _pc() + update_per_channel_ranges(v, _data_cols([(-2.0, 2.0)]), [0], "emg", now=100.0) # snap large + r = update_per_channel_ranges(v, _data_cols([(-0.1, 0.1)]), [0], "emg", now=100.016) + assert r[0][1] > 0.9 * 2.0 # barely contracted after one frame (shrink-slow, no jitter) + t = 100.016 + for _ in range(int(round(_SCALE_EASE_S / 0.016)) + 5): # ~5 s + t += 0.016 + r = update_per_channel_ranges(v, _data_cols([(-0.1, 0.1)]), [0], "emg", now=t) + assert abs(r[0][1] - 0.1) < 0.1 and abs(r[0][0] + 0.1) < 0.1 # settled near the small target + + +def test_per_channel_channel_set_change_snaps_the_new_channel(): + v = _pc() + update_per_channel_ranges(v, _data_cols([(-1.0, 1.0)]), [0], "emg", now=100.0) + update_per_channel_ranges(v, _data_cols([(-1.0, 1.0)]), [0], "emg", now=105.0) # settled + r = update_per_channel_ranges(v, _data_cols([(-1.0, 1.0), (-3.0, 3.0)]), [0, 1], "emg", now=105.016) + assert np.allclose(r[1], (-3.0, 3.0)) # newly-enabled ch1 snaps to its raw range + + +def test_per_channel_display_filter_change_snaps(): + v = _pc() + update_per_channel_ranges(v, _data_cols([(-0.1, 0.1)]), [0], "emg", now=100.0) + update_per_channel_ranges(v, _data_cols([(-0.1, 0.1)]), [0], "emg", now=105.0) # settled small + v.display_filter = "dc_removal" # a scale-changing filter → snap, don't ease across it + r = update_per_channel_ranges(v, _data_cols([(-3.0, 3.0)]), [0], "emg", now=105.016) + assert np.allclose(r[0], (-3.0, 3.0)) + + +def test_per_channel_drops_disabled_channels(): + v = _pc() + update_per_channel_ranges(v, _data_cols([(-1.0, 1.0), (-2.0, 2.0)]), [0, 1], "emg", now=100.0) + assert set(v.pc_ranges) == {0, 1} + update_per_channel_ranges(v, _data_cols([(-1.0, 1.0)]), [0], "emg", now=100.016) # ch1 disabled + assert set(v.pc_ranges) == {0} # dropped, no stale accumulation + + +# --- per-channel MANUAL: freeze the lane ranges (Codex model: basis × adaptation) ----------- + + +def test_per_channel_manual_holds_frozen_ranges(): + v = _pc() + update_per_channel_ranges(v, _data_cols([(-1.0, 1.0), (-2.0, 2.0)]), [0, 1], "emg", now=100.0) + frozen = dict(v.pc_ranges) + v.scale_mode = "manual" + # Even as the signal changes wildly, Manual holds the captured ranges (no re-fit). + r = update_per_channel_ranges(v, _data_cols([(-9.0, 9.0), (-0.01, 0.01)]), [0, 1], "emg", now=101.0) + assert r == frozen and v.pc_ranges == frozen + + +def test_per_channel_manual_initialises_only_a_newly_enabled_channel(): + v = _pc() + update_per_channel_ranges(v, _data_cols([(-1.0, 1.0)]), [0], "emg", now=100.0) # ch0 (auto) + v.scale_mode = "manual" + ch0_frozen = v.pc_ranges[0] + # Enable ch1 while Manual: it initialises from its own current data; ch0 stays frozen. + r = update_per_channel_ranges(v, _data_cols([(-1.0, 1.0), (-3.0, 3.0)]), [0, 1], "emg", now=101.0) + assert r[0] == ch0_frozen + assert np.allclose(r[1], (-3.0, 3.0)) + + +def test_per_channel_manual_resets_ease_clock_for_auto_re_entry(): + v = _pc() + update_per_channel_ranges(v, _data_cols([(-1.0, 1.0)]), [0], "emg", now=100.0) + v.scale_mode = "manual" + update_per_channel_ranges(v, _data_cols([(-1.0, 1.0)]), [0], "emg", now=101.0) + assert v.pc_ease_t == 0.0 # so switching back to Auto snaps rather than easing from stale + + +def test_per_channel_manual_drops_disabled_channels(): + v = _pc() + update_per_channel_ranges(v, _data_cols([(-1.0, 1.0), (-2.0, 2.0)]), [0, 1], "emg", now=100.0) + v.scale_mode = "manual" + r = update_per_channel_ranges(v, _data_cols([(-1.0, 1.0)]), [0], "emg", now=101.0) # ch1 off + assert set(r) == {0} and set(v.pc_ranges) == {0} + + +def test_per_channel_ranges_are_stored_gained(): + # Ranges are stored gained so Manual gain magnifies against the frozen reference. + v = _pc() + v.gain = 5.0 + r = update_per_channel_ranges(v, _data_cols([(-1.0, 1.0)]), [0], "emg", now=100.0) # auto snap + assert np.allclose(r[0], (-5.0, 5.0)) # (-1,1) × gain 5 + + +# --- artifact-robust range (duration-based rejection) --------------------------------------- + + +def _artifact_window(n=50000, contraction=1.0, artifact=10.0, fs=10240.0): + """Window: noise floor + a sustained ~200 ms contraction + a brief ~10 ms artifact spike.""" + rng = np.random.default_rng(0) + x = rng.normal(0, 0.05, n) + x[10000:12000] += rng.normal(0, contraction, 2000) # 4% of samples: the real contraction + x[30000:30100] += rng.normal(0, artifact, 100) # 0.2%: a movement-artifact spike + return x[:, None].astype(np.float32), n / fs + + +def test_robust_rejects_sparse_artifact_keeps_contraction(): + data, window = _artifact_window() + raw = robust_channel_ranges(data, [0], 0.0, window, "none", 100.0, 20.0)[0] # min/max + rob = robust_channel_ranges(data, [0], 20.0, window, "none", 100.0, 20.0)[0] # reject <20 ms + assert raw[1] > 15 # plain min/max is artifact-driven (~+30) + assert 2.0 < rob[1] < 6.0 and -6.0 < rob[0] < -2.0 # robust keeps the ±3 contraction, drops it + + +def test_robust_transient_zero_is_plain_minmax(): + data, window = _artifact_window() + r = robust_channel_ranges(data, [0], 0.0, window, "none", 100.0, 20.0)[0] + assert r[1] > 15 and r[0] < -15 # no rejection → equals raw min/max + + +def test_robust_rms_env_is_one_sided(): + env = np.abs(np.linspace(0.0, 2.0, 300))[:, None].astype(np.float32) + r = robust_channel_ranges(env, [0], 20.0, 5.0, "rms_env", 100.0, 20.0)[0] + assert r[0] == 0.0 and r[1] > 0.0 # lower bound pinned to zero, only the top trimmed + + +def test_robust_auto_scale_ignores_an_artifact_by_default(): + data, window = _artifact_window() + v = ViewerState() # default transient_ms = 20 + v.window = window + update_auto_scale(v, data, [0], "emg", now=100.0) # first frame snaps to the ROBUST range + assert v.y_max < 6.0 # not blown out to the ±30 artifact diff --git a/tests/test_channel_grid.py b/tests/test_channel_grid.py index 327add90..c6eaff87 100644 --- a/tests/test_channel_grid.py +++ b/tests/test_channel_grid.py @@ -8,6 +8,7 @@ from myogestic.stream import ChannelGrid, StreamInfo from myogestic.widgets.signals._channel_grid import ( auto_shape, + grid_arrangement, normalize_layout, rect_to_channels, reduce_selection, @@ -25,6 +26,22 @@ from myogestic.widgets.signals._state import ViewerState +def test_grid_arrangement_is_near_square_columns(): + # 6 grids tile 3 columns x 2 rows (the multi-adapter case); a lone grid + # stays single-column; other counts stay near-square. + assert grid_arrangement(6) == 3 + assert grid_arrangement(1) == 1 + assert grid_arrangement(0) == 1 + assert grid_arrangement(4) == 2 + assert grid_arrangement(9) == 3 + # Never wider than the grid count, and near-square: rows = ceil(n / n_cols) + # never exceeds n_cols (since n_cols = ceil(sqrt(n)) => n_cols**2 >= n). + for n in range(1, 40): + cols = grid_arrangement(n) + assert 1 <= cols <= n + assert (n + cols - 1) // cols <= cols + + def test_channel_grid_columns_and_streaminfo_field(): g = ChannelGrid("IN1", [[0, 1], [2, None]]) assert g.columns == [0, 1, 2] # row-major, skips None diff --git a/tests/test_signal_viewer_decimation.py b/tests/test_signal_viewer_decimation.py index 2853e937..22c62c62 100644 --- a/tests/test_signal_viewer_decimation.py +++ b/tests/test_signal_viewer_decimation.py @@ -36,6 +36,8 @@ from myogestic.stream import Stream, StreamInfo from myogestic.widgets.signals._state import ( + _DECIMATE_FALLBACK_POINTS, + _DECIMATE_MIN_POINTS, ViewerState, build_signal_frame, minmax_grid_all_shared_x, @@ -248,8 +250,9 @@ def test_minmax_grid_all_shared_x_bounds_total_draw_points_by_channel_count(): window_samples = int(window_s * fs) assert raw_len >= window_samples * 0.9 - # A plausible plot pixel width; the default n_pixels=2000 cap binds - # here (matches the reported 64 ch -> 128k-point example: 64 * 2000). + # A plausible plot pixel width; the explicit n_pixels=2000 cap binds + # here (800 * 3.0 detail = 2400, clamped to 2000; matches the reported + # 64 ch -> 128k-point example: 64 * 2000). n_out = resolve_decimation_target(plot_width_px=800.0, v=v) assert n_out == 2000 @@ -635,6 +638,50 @@ def test_resolve_decimation_target_falls_back_to_n_pixels_when_width_unknown(): assert target == 800 +def test_detail_factor_sets_draw_density_relative_to_plot_width(): + """With no cap (default `n_pixels=None`), the drawn point count is + `plot_width_px * detail_factor` (rounded down to a multiple of 4), so the + "Detail" slider scales density directly instead of hitting a fixed cap.""" + width = 600.0 + + full = resolve_decimation_target(width, ViewerState(detail_factor=3.0)) + half = resolve_decimation_target(width, ViewerState(detail_factor=1.0)) + coarse = resolve_decimation_target(width, ViewerState(detail_factor=0.5)) + + assert full == 1800 # 600 * 3.0 + assert half == 600 # 600 * 1.0 + assert coarse == 300 # 600 * 0.5 + assert full > half > coarse + + +def test_default_has_no_cap_so_wide_plots_are_not_clamped(): + """The old default (`n_pixels=2000`) clamped wide plots below the + width-relative target — the dead zone. The new default (`None`) removes + it: a wide plot draws the full `width * 3` with no ceiling.""" + v = ViewerState() # detail_factor=3.0, n_pixels=None + assert v.n_pixels is None + + wide = resolve_decimation_target(plot_width_px=1600.0, v=v) + + assert wide == 4800 # 1600 * 3.0, NOT clamped to 2000 + + +def test_tiny_plot_width_is_floored_not_collapsed(): + """A narrow (or barely laid-out) plot still keeps at least the floor of + points so decimation never collapses the trace to near nothing.""" + target = resolve_decimation_target(plot_width_px=5.0, v=ViewerState(detail_factor=0.5)) + + assert target >= _DECIMATE_MIN_POINTS + + +def test_width_unknown_falls_back_to_default_when_no_explicit_cap(): + """First frame (width reported `<= 0`) with no explicit `n_pixels` cap + uses the module fallback, then self-corrects once the plot has a size.""" + target = resolve_decimation_target(plot_width_px=0.0, v=ViewerState()) + + assert target == _DECIMATE_FALLBACK_POINTS + + def test_initial_channels_seeds_first_open_only(): """`initial_channels` picks the opening selection, but only once — a later user edit (simulated by mutating `v.channels` directly, as the diff --git a/tests/test_viz.py b/tests/test_viz.py index 43e42841..36ca72f9 100644 --- a/tests/test_viz.py +++ b/tests/test_viz.py @@ -64,6 +64,77 @@ def test_heatmap_renders_with_per_cell_ticks(implot_frame): implot_frame(lambda: hm.ui(np.arange(6.0).reshape(2, 3))) +def test_heatmap_shared_vrange_renders(implot_frame): + """A shared `vrange` renders for grids whose own ranges differ wildly. + + Without it every instance maps its own min/max, so a quiet electrode array + and a loud one render identically — the failure mode when several arrays + are meant to be compared side by side. Also covers degenerate ranges. + """ + quiet = np.full((2, 2), 0.01) + loud = np.array([[5.0, 9.0], [1.0, 7.0]]) + shared = (0.0, 9.0) + hm = Heatmap("Array") + implot_frame(lambda: hm.ui(quiet, vrange=shared)) + implot_frame(lambda: hm.ui(loud, vrange=shared)) + implot_frame(lambda: hm.ui(quiet, vrange=(0.0, 0.0))) # degenerate: lo == hi + implot_frame(lambda: hm.ui(np.zeros((2, 2)))) # flat data, no vrange + + +def test_widget_id_gives_each_viewer_its_own_state(): + """Several viewers on ONE stream must not share state. + + Without a distinct `widget_id` every `SignalViewer("emg")` resolves to the + same ViewerState, so a per-electrode-grid panel layout would render five + identical tiles. Default (no widget_id) still keys by stream name. + """ + from types import SimpleNamespace + + from myogestic.widgets.signals._state import get_viewer_state + + ctx = SimpleNamespace(streams={}) + kw = dict( + n_pixels=None, + scale_mode="auto", + y_range=(-1.0, 1.0), + show_markers=False, + window_s=1.0, + stream_name="emg", + ) + a = get_viewer_state(ctx, "tviz_IN1", **kw) + b = get_viewer_state(ctx, "tviz_IN2", **kw) + assert a is not b # distinct ids -> independent state + assert a is get_viewer_state(ctx, "tviz_IN1", **kw) # same id -> same state + + a.channels = {1, 2} + b.channels = {7} + a.paused = True + assert b.channels == {7} and not b.paused # no cross-talk + + # Defaulting widget_id to the stream name keeps the single-viewer behaviour. + solo = get_viewer_state(ctx, "tviz_emg", **{**kw, "stream_name": None}) + assert solo is get_viewer_state(ctx, "tviz_emg", **kw) + + +def test_show_controls_seeds_from_constructor_arg(): + """`show_controls=False` opens a tiled panel with the menu collapsed.""" + from types import SimpleNamespace + + from myogestic.widgets.signals._state import get_viewer_state + + ctx = SimpleNamespace(streams={}) + kw = dict( + n_pixels=None, + scale_mode="auto", + y_range=(-1.0, 1.0), + show_markers=False, + window_s=1.0, + stream_name="emg", + ) + assert get_viewer_state(ctx, "tviz_hidden", show_controls=False, **kw).show_controls is False + assert get_viewer_state(ctx, "tviz_shown", **kw).show_controls is True + + def test_log_panel_renders_with_horizontal_scroll(imgui_frame): """The log panel renders long (non-wrapping) lines, the empty state, the narrow header (Clear button drops below), and the header-less variant.""" From 3e5064363deec9606567cfedcb3dbcf996868438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Fri, 24 Jul 2026 20:25:01 +0200 Subject: [PATCH 2/4] docs(design): write down the visual language, and align two controls to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The theme was a real, deliberate system — Instrument Serif / IBM Plex Mono, the tint+underline "this is on" cue, borderless transparent plots, tab10 for series identity — but it lived only in the code, so new controls drifted from it. concepts/visual-language.md states it: type, colour, panel headers, state cues, plot styling, unit formatting, pop-out vs collapse, and widget identity. It sits beside design-principles.md, which stays the code contract. Two controls that had drifted: - The panel chrome toggle now carries push_selected/pop_selected when collapsed, like every other sticky toggle (Edit…, Manual). A bare icon over a plot read as an unrelated action rather than a live state. - "Reject <" becomes "Artifact", with the slider formatting the threshold as "< 20 ms". A label should say what the control does, spelled out; a truncated operator symbol is jargon to whoever is running the session. Two other suspected drifts did not survive checking, and are left alone: panel titles already pass through panel_header's uppercase/muted/icon/ellipsis treatment, and %/x/s are each correct for the quantity they label — that was a missing written rule, now in the doc, not a defect. --- CHANGELOG.md | 2 +- docs/concepts/index.md | 1 + docs/concepts/visual-language.md | 111 +++++++++++++++++++++++++ myogestic/widgets/signals/_controls.py | 7 +- myogestic/widgets/signals/viewer.py | 7 +- properdocs.yml | 1 + 6 files changed, 125 insertions(+), 4 deletions(-) create mode 100644 docs/concepts/visual-language.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 394eae92..84537400 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and locks; **Gain** is live in per-channel Manual (magnifies each trace against its frozen range) and inert in per-channel Auto. - **Signal viewer — artifact-robust y-scale.** Fitting the range (Auto, per-channel, and Rescale) - now ignores transients shorter than a configurable budget (**"Reject <" control, default 20 ms**), + now ignores transients shorter than a configurable budget (**"Artifact" control, default 20 ms**), so a brief movement-artifact spike no longer blows up the scale and dwarfs the real EMG. It works by *duration*, not amplitude — the visible window is split into equal-time bins and the few most extreme bin-maxima/minima that a sub-budget transient could occupy are dropped — because a 10 ms diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 081d1291..d0ecf3a8 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -13,3 +13,4 @@ Drill in: - [Widgets](widgets.md) - the stateless-function contract, ImGui immediate mode, `Grid` layout. - [Threading](threading.md) - daemon threads, GIL release, the GPU contention rule. - [Design principles](design-principles.md) - the eight rules the codebase keeps to. +- [Visual language](visual-language.md) - the type, colour, panel and state-cue tokens every widget uses. diff --git a/docs/concepts/visual-language.md b/docs/concepts/visual-language.md new file mode 100644 index 00000000..2ae164bd --- /dev/null +++ b/docs/concepts/visual-language.md @@ -0,0 +1,111 @@ +# Visual language + +[Design principles](design-principles.md) is the *code* contract — no base classes, one name one +meaning, a public API that fits on a page. This page is the *visual* one: the small set of tokens +and rules every widget already follows, so a new control looks like it belongs instead of like a +stock ImGui default. + +Everything here exists in [`myogestic/_theme.py`](https://github.com/NsquaredLab/MyoGestic/blob/main/myogestic/_theme.py) +and [`myogestic/widgets/common.py`](https://github.com/NsquaredLab/MyoGestic/blob/main/myogestic/widgets/common.py). +Reach for the helper; don't re-derive the styling inline. + +## Type + +| Role | Face | Helper | +| --- | --- | --- | +| Hero / display text | Instrument Serif | `display_font()` | +| Console, logs, anything columnar | IBM Plex Mono | `mono_font()` | +| Everything else | the theme's UI face | — | + +Size is never hardcoded against pixels: the global scale resolves +`$MYOGESTIC_UI_SCALE` → `App(ui_scale=…)` → `1.0` (`set_ui_scale`), so a control sized in +`imgui.get_frame_height()` units follows the user's display and a control sized in raw pixels +does not. + +## Colour + +- **`PALETTE`** — ten categorical colours, for *series identity* (channel 0 vs channel 1). Never + use it as a ramp: adjacent entries carry no ordering. +- **Continuous data gets a perceptually-uniform ramp** — viridis is `Heatmap`'s default precisely + because ImPlot's stock "Deep" is categorical and misleads on a heatmap. +- **Semantic tone comes from the theme**, not literals: `Col_.text_disabled` for muted text, + `Col_.child_bg` for cell surfaces. That is what keeps light and dark themes honest. + +## Panels + +`panel_header(title, icon)` renders the one true panel title: **uppercased, muted +(`text_disabled`), optional Font-Awesome icon, ellipsis-truncated** when the panel is too narrow +(icon-only when there is no room at all). Pass `reserve=` to keep space for a right-aligned +control so the *title* collapses instead of pushing it off. + +`panel_header_button(title, icon, button_icon)` is the same header with one right-aligned +icon-only action, which drops to its own line when the row is too tight. + +Never `imgui.text()` a panel title directly — you lose the casing, the muting, the icon and the +truncation, and the panel stops matching its neighbours. + +## State cues + +| Cue | Helper | Means | +| --- | --- | --- | +| Translucent accent tint + 2 px accent underline | `push_selected()` / `pop_selected()` | "this is on" | +| Raised chip among flat segments | `segmented()` | one-of-N choice, all options visible | +| Colour flash decaying over ~0.18 s | `flash_color()` | "this value just updated" | + +The selected cue is deliberately a *tint*, not a solid fill: it should read as selection, not as a +button caught mid-press. **Any control with a sticky on/off state uses it** — the channel grid's +`Edit…`, the Manual scale-mode button, and the panel chrome toggle all do. + +Inline actions that sit in a row of pills (`All` / `None` / `Invert` / `Edit…`) use +`imgui.small_button` — a full-height `button` sits out of line with them. + +## Plots + +Call `ensure_implot_style()` at the top of any plot widget. Plots then read as part of the app: +no chart border (the surface tone frames them), a transparent plot background so the card shows +through, and a faint grid. + +**Comparison needs a shared range.** Any time several plots are meant to be read against each +other, they must share one scale — `Heatmap.ui(vrange=…)` for colour, a common manual `y_range` +for traces. With per-instance autoscaling a quiet electrode array and a loud one render +identically, which silently inverts the conclusion the operator draws. + +## Units + +Label a control in the unit the operator thinks in, not the one the code stores: + +| Quantity | Format | Example | +| --- | --- | --- | +| Proportion of a maximum | `%.0f%%` | Detail `100%` | +| Multiplier | `%.2fx` | Gain `1.00x` | +| Physical quantity | its own unit | Window `1.0 s`, Artifact `< 20 ms` | + +A label states what the control *does*, spelled out — `Artifact < 20 ms`, not `Reject <`. +Truncated or operator-symbol labels read as jargon to the clinician running the session. + +## Space + +Two different affordances, two different jobs — don't substitute one for the other: + +- **Pop out** (`popout_panel`) — the panel becomes its own dockable, tearable window. For a panel + the user wants *bigger*, or on another monitor. +- **Collapse chrome** (`SignalViewer(show_controls=…)` and its `≡` header toggle) — title, + controls, channel bar and footer fold away, leaving the plot. For a panel whose cell is + *fixed* — a tile in a `Grid` — where the chrome costs more than it gives. + +Layout itself is always [`Grid`](grid-layout.md) with `Px`/`Fr` tracks. Widgets do not position +themselves. + +## Identity + +State is keyed by **widget identity**, never by the data it happens to show (rule 8 of the +[design principles](design-principles.md)). Widgets that can appear more than once take an explicit +`widget_id`, defaulting to the natural single-instance name: + +```python +Heatmap("IN1", widget_id="grid:IN1") # defaults to the label +SignalViewer("emg", widget_id="emg:grid:IN1") # defaults to the stream name +``` + +Without it, two instances share one state and render identically. Prefer a stable, unique string — +user-facing labels can repeat. diff --git a/myogestic/widgets/signals/_controls.py b/myogestic/widgets/signals/_controls.py index b1c51106..2e0ebd4e 100644 --- a/myogestic/widgets/signals/_controls.py +++ b/myogestic/widgets/signals/_controls.py @@ -279,10 +279,13 @@ def render_resolution_controls( imgui.end_disabled() imgui.table_next_column() - imgui.text("Reject <") + imgui.text("Artifact") imgui.same_line() imgui.set_next_item_width(-1) - changed_t, new_t = imgui.slider_float(f"##{stream_name}_transient", v.transient_ms, 0.0, 40.0, "%.0f ms") + # Reads as "Artifact < 20 ms" — the label states what it does, spelled out. + changed_t, new_t = imgui.slider_float( + f"##{stream_name}_transient", v.transient_ms, 0.0, 40.0, "< %.0f ms" + ) if changed_t: v.transient_ms = new_t if imgui.is_item_hovered(): diff --git a/myogestic/widgets/signals/viewer.py b/myogestic/widgets/signals/viewer.py index fcaf3ac0..cdcd9c7c 100644 --- a/myogestic/widgets/signals/viewer.py +++ b/myogestic/widgets/signals/viewer.py @@ -21,7 +21,7 @@ def my_ui(ctx): from imgui_bundle import icons_fontawesome_6 as fa from imgui_bundle import imgui -from myogestic.widgets.common import panel_header_button +from myogestic.widgets.common import panel_header_button, pop_selected, push_selected from myogestic.widgets.signals._controls import ( render_channel_controls, render_controls, @@ -167,7 +167,12 @@ def ui(self, ctx: Context) -> None: tooltip="Hide title, controls, channel bar and footer", ) else: + # Collapsed is the non-default state, so the lone icon carries the app's + # "this is on" cue (tint + underline) — otherwise a bare button floating + # above a plot reads as an unrelated action rather than a live toggle. + push_selected() toggled = imgui.small_button(f"{fa.ICON_FA_BARS}##{wid}_show_chrome") + pop_selected() if imgui.is_item_hovered(): imgui.set_tooltip("Show title, controls, channel bar and footer") if toggled: diff --git a/properdocs.yml b/properdocs.yml index c27fc987..e34501db 100644 --- a/properdocs.yml +++ b/properdocs.yml @@ -160,6 +160,7 @@ nav: - Edge trigger: concepts/edge-trigger.md - Threading: concepts/threading.md - Design principles: concepts/design-principles.md + - Visual language: concepts/visual-language.md - Widget gallery: widget-gallery.md - Troubleshooting: troubleshooting.md - Reference: From 09d37a9a2dc4e036ebf60ecc515329ac5abf23e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Fri, 24 Jul 2026 20:40:38 +0200 Subject: [PATCH 3/4] fix(theme): route every widget colour through the theme, and enforce it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Writing the visual language down made it checkable, and the check found real bugs. Nineteen colours were typed as literals across eight widget files, so they could not follow the active theme: the session-manager label and the raw viewer's footer were near-white (washed out on the light theme), the channel-grid hover outline was white-on-white and therefore invisible, and the prediction readout flashed *toward white* — into the card it sits on. Those now read the theme through new common.py helpers (muted, primary, hairline) or an existing token. Colours deliberately fixed in both themes are named rather than inlined: CONSOLE_BG / CONSOLE_TEXT for the terminal surface (a terminal stays dark on a light desktop) and PILL_BG / ON_PILL_TEXT for the status badge. ProcessLauncher's hand-rolled red/green become the shared DANGER / SUCCESS, so Stop/Launch match status everywhere else. Separately, RawSignalViewer called implot.begin_plot without ensure_implot_style(), so it rendered with stock ImPlot chrome — border, opaque background, heavy grid — instead of the app's plot style. That is the bug the second rule was written to catch, and it had shipped. tests/test_visual_language.py enforces both rules, with no allowlist. It is AST-based, not textual: a docstring in _state.py mentions begin_plot and a grep version flagged it. Both rules were verified to fail when a violation is reintroduced. Rules that need judgement — is this the right cue, does this comparison need a shared range — are deliberately left to review. CLAUDE.md points agents at both contracts before they touch a widget, which is what would have prevented the drift in the first place. --- CHANGELOG.md | 23 ++++ CLAUDE.md | 47 ++++++++ myogestic/widgets/common.py | 37 ++++++ myogestic/widgets/panels/log_box.py | 10 +- myogestic/widgets/panels/process_launcher.py | 6 +- myogestic/widgets/panels/recording.py | 14 ++- myogestic/widgets/signals/_controls.py | 16 ++- myogestic/widgets/signals/_plot.py | 9 +- myogestic/widgets/signals/_scan.py | 4 +- myogestic/widgets/signals/raw.py | 10 +- myogestic/widgets/signals/stream_panel.py | 15 ++- .../widgets/training/prediction_label.py | 11 +- myogestic/widgets/training/session_manager.py | 15 ++- tests/test_visual_language.py | 107 ++++++++++++++++++ 14 files changed, 281 insertions(+), 43 deletions(-) create mode 100644 CLAUDE.md create mode 100644 tests/test_visual_language.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 84537400..0592f3de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 for a coarser, cheaper trace when many channels tax the frame rate. The `SignalViewer(n_pixels=…)` constructor arg is demoted to an optional hard-cap override (default `None` = no cap). +### Fixed + +- **Light theme: hardcoded colours now follow the theme.** Nineteen colours were typed as literals + across eight widget files and could not respond to the active theme — a near-white session-manager + label and the raw viewer's footer were washed out on the light theme, the channel-grid hover + outline was white-on-white (invisible), and the prediction readout flashed *toward white*, i.e. + into the card. They now read the theme (`muted()` / `primary()` / `hairline()`) or a named token. + Colours that are deliberately fixed in both themes (the console surface, the status pill) are now + named tokens in `widgets/common.py` rather than inline literals. +- **`RawSignalViewer` renders in the app's plot style.** It called `implot.begin_plot` without + `ensure_implot_style()`, so it drew with stock ImPlot chrome — chart border, opaque background, + heavy grid — instead of matching every other plot. +- **`ProcessLauncher`** used its own red/green rather than the shared `DANGER` / `SUCCESS` status + colours, so Stop/Launch didn't match status elsewhere in the app. + +### Added + +- **`docs/concepts/visual-language.md`** — the visual contract (type, colour, panel headers, state + cues, plot styling, units, pop-out vs collapse, widget identity), beside the existing code + contract in `design-principles.md`. `tests/test_visual_language.py` enforces the two rules a + machine can decide honestly: no colour literals outside the design layer, and every module that + opens a plot styles it. + ## [2.3.2] - 2026-07-23 ### Changed diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..bc76843b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,47 @@ +# MyoGestic — working notes for agents + +A real-time biosignal app framework (imgui/implot). Read this before editing; it is short +because the real contracts live in two docs. + +## The two contracts + +- **[docs/concepts/design-principles.md](docs/concepts/design-principles.md)** — the *code* + contract. No base classes, no inheritance, no registration, no config files. Every widget is a + single public class/function with typed arguments. Widget state is keyed by **widget identity**, + not by the data it happens to show. +- **[docs/concepts/visual-language.md](docs/concepts/visual-language.md)** — the *visual* contract. + Read it before adding or restyling any control. The theme is deliberate; the most common way to + break it is to reach for a literal instead of a token. + +Two of its rules are enforced by `tests/test_visual_language.py`: + +1. **Never hardcode a colour** outside `_theme.py` / `widgets/common.py`. Use a token + (`SUCCESS`, `DANGER`, `PILL_BG`, `CONSOLE_BG`, …) or read the theme (`muted()`, `primary()`, + `hairline()`). A literal tuned on the dark theme goes invisible on the light one. If a colour is + deliberately fixed in *both* themes, name it in `common.py` — do not inline it. +2. **Call `ensure_implot_style()`** in any module that calls `implot.begin_plot`, or the plot + renders as stock ImPlot instead of as part of the app. + +The rest of the visual language is not machine-checkable and is on you: use `panel_header` for +panel titles (never a raw `imgui.text`), `push_selected`/`pop_selected` for any sticky on/off +control, `PALETTE` only for categorical series identity (never as a ramp), and a **shared range** +whenever several plots are meant to be compared — with per-instance autoscaling a quiet signal and +a loud one render identically. + +## Conventions + +- **Style**: 4-space indent, double quotes, NumPy-style docstrings. `pydocstyle` (ruff `D`) is + CI-enforced. Run `uv run ruff check .`. +- **Tests**: `uv run --extra dev pytest -q`. `tests/test_stream_lsl.py::test_stream_reconnect_swaps_buffers_atomically` + is flaky under full-suite load (LSL multicast contention) and passes in isolation — it is not + your change. +- **Docs are tested.** `tests/test_docs.py` parses and *runs* Python blocks in `docs/`, so a code + block in a doc page must actually work. New doc pages go in `properdocs.yml` and the relevant + `index.md`. +- **Layout** is always `Grid` with `Px`/`Fr` tracks — widgets never position themselves. +- Prefer extending a token/helper in `widgets/common.py` over adding a one-off in a widget file. + +## Commits + +Do **not** add AI attribution — no `Co-Authored-By: Claude`, no "Generated with" footer, in commit +messages or PR bodies. diff --git a/myogestic/widgets/common.py b/myogestic/widgets/common.py index abc2b504..2f5987c3 100644 --- a/myogestic/widgets/common.py +++ b/myogestic/widgets/common.py @@ -35,6 +35,43 @@ IDLE = imgui.ImVec4(142 / 255, 142 / 255, 147 / 255, 1.0) # systemGray +# Surfaces that deliberately keep ONE look in both themes, because they imitate +# a physical thing rather than the app's own chrome: a terminal stays dark on a +# light desktop, and a status pill reads as a badge printed on the panel. Fixed +# on purpose — everything else must come from the theme (see `muted` / `primary`). +CONSOLE_BG = imgui.ImVec4(0.075, 0.078, 0.086, 1.0) +CONSOLE_TEXT = imgui.ImVec4(0.88, 0.89, 0.91, 1.0) +PILL_BG = imgui.ImVec4(0.14, 0.17, 0.21, 1.0) +ON_PILL_TEXT = imgui.ImVec4(0.93, 0.95, 0.98, 1.0) + + +def primary() -> imgui.ImVec4: + """The theme's main text colour — resolved per call, so it follows light/dark. + + Colour must never be hardcoded in a widget: a literal that looks right on + the dark theme disappears on the light one. Read the slot instead. + """ + return imgui.get_style().color_(imgui.Col_.text) + + +def muted() -> imgui.ImVec4: + """The theme's secondary-text colour — labels, units, footers, separators. + + See [`primary`][] for why this is a call and not a constant. + """ + return imgui.get_style().color_(imgui.Col_.text_disabled) + + +def hairline(alpha: float = 1.0) -> imgui.ImVec4: + """The theme's separator/border colour, optionally faded to ``alpha``. + + For draw-list outlines (grid cells, overlays) that must stay visible on + both themes. + """ + c = imgui.get_style().color_(imgui.Col_.border) + return imgui.ImVec4(c.x, c.y, c.z, c.w * alpha) + + _flash_state: dict[str, tuple[object, float]] = {} diff --git a/myogestic/widgets/panels/log_box.py b/myogestic/widgets/panels/log_box.py index 5f879b43..4ca11d22 100644 --- a/myogestic/widgets/panels/log_box.py +++ b/myogestic/widgets/panels/log_box.py @@ -19,11 +19,7 @@ from imgui_bundle import imgui from myogestic._theme import mono_font - -# A sunken dark "console" surface with light mono text — reads as program -# output in both light and dark themes (terminals stay dark on a light UI). -_CONSOLE_BG = imgui.ImVec4(0.075, 0.078, 0.086, 1.0) -_CONSOLE_TEXT = imgui.ImVec4(0.88, 0.89, 0.91, 1.0) +from myogestic.widgets.common import CONSOLE_BG, CONSOLE_TEXT def render_log( @@ -63,8 +59,8 @@ def render_log( h = height if height > 0 else -1.0 # Console styling on the selectable read-only box: a sunken dark surface # (its frame bg) + light monospace text, in both themes. - imgui.push_style_color(imgui.Col_.frame_bg, _CONSOLE_BG) - imgui.push_style_color(imgui.Col_.text, _CONSOLE_TEXT) + imgui.push_style_color(imgui.Col_.frame_bg, CONSOLE_BG) + imgui.push_style_color(imgui.Col_.text, CONSOLE_TEXT) font = mono_font() if font is not None: imgui.push_font(font, imgui.get_font_size()) diff --git a/myogestic/widgets/panels/process_launcher.py b/myogestic/widgets/panels/process_launcher.py index 42a357a5..73328756 100644 --- a/myogestic/widgets/panels/process_launcher.py +++ b/myogestic/widgets/panels/process_launcher.py @@ -23,7 +23,7 @@ def my_ui(ctx): from imgui_bundle import icons_fontawesome_6 as fa from imgui_bundle import imgui -from myogestic.widgets.common import IDLE, SUCCESS, panel_header +from myogestic.widgets.common import DANGER, IDLE, SUCCESS, panel_header from myogestic.widgets.panels.log_box import ( render_log, render_log_buttons, @@ -238,13 +238,13 @@ def _render_process_launcher( if inline: imgui.same_line() if proc is not None and proc.poll() is None: - imgui.push_style_color(imgui.Col_.button, imgui.ImVec4(0.6, 0.15, 0.15, 1.0)) + imgui.push_style_color(imgui.Col_.button, DANGER) if imgui.button(f"Stop##{widget_id}"): state.stop() imgui.pop_style_color() imgui.set_item_tooltip(f"Kill the running '{selected_name}' process (SIGKILL).") else: - imgui.push_style_color(imgui.Col_.button, imgui.ImVec4(0.15, 0.4, 0.15, 1.0)) + imgui.push_style_color(imgui.Col_.button, SUCCESS) if imgui.button(f"Launch##{widget_id}"): try: state.start() diff --git a/myogestic/widgets/panels/recording.py b/myogestic/widgets/panels/recording.py index 429bbe39..1dde3612 100644 --- a/myogestic/widgets/panels/recording.py +++ b/myogestic/widgets/panels/recording.py @@ -26,7 +26,15 @@ def my_ui(ctx): from imgui_bundle import imgui from myogestic.core import AppState -from myogestic.widgets.common import DANGER, IDLE, panel_header, pop_selected, push_selected +from myogestic.widgets.common import ( + DANGER, + IDLE, + ON_PILL_TEXT, + PILL_BG, + panel_header, + pop_selected, + push_selected, +) if TYPE_CHECKING: from myogestic.core import Context @@ -50,7 +58,7 @@ def _status_pill(label: str, color: imgui.ImVec4) -> None: p0, p1 = imgui.get_item_rect_min(), imgui.get_item_rect_max() draw = imgui.get_window_draw_list() draw.add_rect_filled( - p0, p1, imgui.get_color_u32(imgui.ImVec4(0.14, 0.17, 0.21, 1.0)), size.y * 0.5 + p0, p1, imgui.get_color_u32(PILL_BG), size.y * 0.5 ) y = (p0.y + p1.y) * 0.5 draw.add_rect_filled( @@ -61,7 +69,7 @@ def _status_pill(label: str, color: imgui.ImVec4) -> None: ) draw.add_text( imgui.ImVec2(p0.x + pad_x + 10, p0.y + pad_y), - imgui.get_color_u32(imgui.ImVec4(0.93, 0.95, 0.98, 1.0)), + imgui.get_color_u32(ON_PILL_TEXT), label, ) diff --git a/myogestic/widgets/signals/_controls.py b/myogestic/widgets/signals/_controls.py index 2e0ebd4e..55e23980 100644 --- a/myogestic/widgets/signals/_controls.py +++ b/myogestic/widgets/signals/_controls.py @@ -7,7 +7,14 @@ from imgui_bundle import icons_fontawesome_6 as fa from imgui_bundle import imgui -from myogestic.widgets.common import PALETTE, pop_selected, push_selected, segmented +from myogestic.widgets.common import ( + PALETTE, + hairline, + pop_selected, + primary, + push_selected, + segmented, +) from myogestic.widgets.signals._channel_grid import ( grid_arrangement, normalize_layout, @@ -692,7 +699,7 @@ def render_cell( else: # Dim + hollow border: an on/off cue beyond brightness alone # (colorblind-safe) — a filled dot vs. no dot, not just color. - border = imgui.color_convert_float4_to_u32(imgui.ImVec4(0.5, 0.5, 0.5, 0.6)) + border = imgui.color_convert_float4_to_u32(hairline(0.6)) dl.add_rect(p_min, p_max, border, rounding=rounding) _draw_cell_label(dl, p_min, p_max, ch) @@ -701,7 +708,10 @@ def render_cell( hovered_ch = ch name = ch_names[ch] if ch_names and ch < len(ch_names) else f"ch{ch}" imgui.set_tooltip(f"{grid_label} · col {ch} · {name}") - highlight = imgui.color_convert_float4_to_u32(imgui.ImVec4(1.0, 1.0, 1.0, 0.8)) + # Hover outline from the text slot, not literal white — white on white is + # invisible on the light theme. + hi = primary() + highlight = imgui.color_convert_float4_to_u32(imgui.ImVec4(hi.x, hi.y, hi.z, 0.8)) dl.add_rect(p_min, p_max, highlight, rounding=rounding, thickness=1.5) if imgui.is_item_focused() and imgui.is_key_pressed(imgui.Key.space, repeat=False): diff --git a/myogestic/widgets/signals/_plot.py b/myogestic/widgets/signals/_plot.py index d708e473..535e257a 100644 --- a/myogestic/widgets/signals/_plot.py +++ b/myogestic/widgets/signals/_plot.py @@ -8,7 +8,7 @@ from imgui_bundle import icons_fontawesome_6 as fa from imgui_bundle import imgui, implot -from myogestic.widgets.common import PALETTE, ensure_implot_style +from myogestic.widgets.common import PALETTE, ensure_implot_style, muted from myogestic.widgets.signals._state import minmax_grid_all_shared_x, resolve_decimation_target if TYPE_CHECKING: @@ -515,7 +515,8 @@ def render_markers( by_class.setdefault(ev.class_index, []).append(float(ev.timestamp - t0)) for ci, xs_list in by_class.items(): if ci < 0: - color = imgui.ImVec4(0.7, 0.7, 0.7, 0.6) + m = muted() # unlabeled markers ride the theme's secondary tone + color = imgui.ImVec4(m.x, m.y, m.z, 0.6) else: c = PALETTE[ci % len(PALETTE)] color = imgui.ImVec4(c[0], c[1], c[2], 0.7) @@ -634,7 +635,7 @@ def render_footer( pts_str = f"{raw_len:,} pts/ch (raw)" imgui.text_colored( - imgui.ImVec4(0.5, 0.5, 0.5, 1.0), + muted(), f"{ui_fps:.0f} fps ({avg_ms:.1f} ms viewer) | " f"fs={stream.info.fs:.0f} Hz | " f"{frame.n_channels} ch | " @@ -670,6 +671,6 @@ def render_footer( for i, ch in enumerate(valid_channels): name = ch_names[ch] if ch_names and ch < len(ch_names) else f"ch{ch}" imgui.text_colored( - imgui.ImVec4(0.55, 0.58, 0.62, 1.0), + muted(), f" {name}: rms {rms_all[i]:.3f} pp {pp_all[i]:.3f} mean {mean_all[i]:+.3f}", ) diff --git a/myogestic/widgets/signals/_scan.py b/myogestic/widgets/signals/_scan.py index 399775a8..18b76991 100644 --- a/myogestic/widgets/signals/_scan.py +++ b/myogestic/widgets/signals/_scan.py @@ -10,7 +10,7 @@ from imgui_bundle import imgui -from myogestic.widgets.common import DANGER +from myogestic.widgets.common import DANGER, muted @dataclass @@ -93,7 +93,7 @@ def _disconnected_ui(stream_name: str, stream: object) -> None: imgui.text_colored(DANGER, f"{stream_name}: disconnected") if stream.last_error: imgui.same_line() - imgui.text_colored(imgui.ImVec4(0.6, 0.6, 0.6, 1.0), f"({stream.last_error})") + imgui.text_colored(muted(), f"({stream.last_error})") if imgui.button(f"Reconnect##{stream_name}"): import sys as _sys diff --git a/myogestic/widgets/signals/raw.py b/myogestic/widgets/signals/raw.py index 7f848973..82849c3b 100644 --- a/myogestic/widgets/signals/raw.py +++ b/myogestic/widgets/signals/raw.py @@ -14,7 +14,7 @@ import numpy as np from imgui_bundle import imgui, implot -from myogestic.widgets.common import PALETTE +from myogestic.widgets.common import PALETTE, ensure_implot_style, muted from myogestic.widgets.signals._scan import _disconnected_ui if TYPE_CHECKING: @@ -126,7 +126,10 @@ def ui(self, ctx: Context) -> None: imgui.Col_.button, imgui.ImVec4(color[0], color[1], color[2], 0.7) ) else: - imgui.push_style_color(imgui.Col_.button, imgui.ImVec4(0.3, 0.3, 0.3, 0.5)) + # Off channels take the theme's own control fill, dimmed — a fixed + # grey read as "disabled" on dark and as a smudge on light. + off = imgui.get_style().color_(imgui.Col_.button) + imgui.push_style_color(imgui.Col_.button, imgui.ImVec4(off.x, off.y, off.z, 0.5)) label = ch_names[ch] if ch_names and ch < len(ch_names) else f"ch{ch}" if imgui.button(f"{label}##{stream_name}_rawtog{ch}"): if is_on: @@ -163,6 +166,7 @@ def ui(self, ctx: Context) -> None: data_range = d_max - d_min channel_height = data_range * 1.2 if data_range > 0 else 1.0 + ensure_implot_style() # without it this plot renders as stock ImPlot, not the app if len(r.specs) < n_channels: r.specs = [] for ch in range(n_channels): @@ -195,7 +199,7 @@ def ui(self, ctx: Context) -> None: avg_ms = np.mean(r.fps) * 1000 fps = 1000.0 / avg_ms if avg_ms > 0 else 0 imgui.text_colored( - imgui.ImVec4(0.5, 0.5, 0.5, 1.0), + muted(), f"{fps:.0f} fps ({avg_ms:.1f} ms) | " f"fs={stream.info.fs:.0f} Hz | " f"{len(enabled)}/{n_channels} ch | " diff --git a/myogestic/widgets/signals/stream_panel.py b/myogestic/widgets/signals/stream_panel.py index 8de87397..b76db72b 100644 --- a/myogestic/widgets/signals/stream_panel.py +++ b/myogestic/widgets/signals/stream_panel.py @@ -17,7 +17,7 @@ from imgui_bundle import icons_fontawesome_6 as fa from imgui_bundle import imgui -from myogestic.widgets.common import DANGER, SUCCESS, panel_header +from myogestic.widgets.common import DANGER, SUCCESS, muted, panel_header from myogestic.widgets.signals._scan import _scans, _ScanState if TYPE_CHECKING: @@ -26,7 +26,6 @@ _OK = SUCCESS _BAD = DANGER -_MUTED = imgui.ImVec4(0.65, 0.65, 0.65, 1.0) # Streams we've already auto-discovered once. Forces the auto-scan to fire # only on the first frame each stream is observed disconnected — the user's @@ -65,7 +64,7 @@ def ui(self, ctx: Context) -> None: panel_header("Streams", fa.ICON_FA_PLUG) if not ctx.streams: - imgui.text_colored(_MUTED, "(no streams registered)") + imgui.text_colored(muted(), "(no streams registered)") return for name, stream in ctx.streams.items(): @@ -89,7 +88,7 @@ def _stream_row(name: str, stream: object, *, selectable: bool) -> None: imgui.same_line() imgui.text(name) imgui.same_line() - imgui.text_colored(_MUTED, f"({src_label})") + imgui.text_colored(muted(), f"({src_label})") # Push the action buttons to the right edge of the available content. btns_w = 60.0 if has_discover and selectable else 28.0 @@ -126,14 +125,14 @@ def _stream_row(name: str, stream: object, *, selectable: bool) -> None: last_ts_age = _last_ts_age(stream) age_text = f"last {last_ts_age * 1000:.0f} ms" if last_ts_age is not None else "—" imgui.text_colored( - _MUTED, + muted(), f"{info.fs:.0f} Hz · {info.n_channels} ch · window {stream._window:.2f}s · {age_text}", ) else: if stream.last_error: - imgui.text_colored(_MUTED, stream.last_error) + imgui.text_colored(muted(), stream.last_error) else: - imgui.text_colored(_MUTED, "disconnected — waiting for source") + imgui.text_colored(muted(), "disconnected — waiting for source") # Auto-kick a scan on first disconnect so buttons appear without a click. if has_discover and selectable and name not in _auto_scanned: @@ -177,7 +176,7 @@ def _connect_buttons(name: str, stream: Stream, scan: _ScanState) -> None: Wraps when the next button would overflow the available width. """ if scan.busy: - imgui.text_colored(_MUTED, "scanning…") + imgui.text_colored(muted(), "scanning…") return if not scan.results: return diff --git a/myogestic/widgets/training/prediction_label.py b/myogestic/widgets/training/prediction_label.py index b995ab46..29060320 100644 --- a/myogestic/widgets/training/prediction_label.py +++ b/myogestic/widgets/training/prediction_label.py @@ -34,7 +34,7 @@ def ui(ctx): from imgui_bundle import imgui from myogestic._theme import display_font -from myogestic.widgets.common import PALETTE, flash_color, panel_header +from myogestic.widgets.common import PALETTE, flash_color, panel_header, primary if TYPE_CHECKING: from myogestic.ml import Pipeline @@ -120,11 +120,10 @@ def ui(self) -> None: name = self._class_names[idx] rgb = PALETTE[idx % len(PALETTE)] rgba = imgui.ImVec4(float(rgb[0]), float(rgb[1]), float(rgb[2]), 1.0) - # Flash toward white when the predicted class changes — a subtle - # "new prediction" pulse on the hero readout. - rgba = flash_color( - self._widget_id or self._title, idx, rgba, imgui.ImVec4(1.0, 1.0, 1.0, 1.0) - ) + # Flash toward the theme's text colour when the predicted class changes + # — a subtle "new prediction" pulse on the hero readout. Not literal + # white: on the light theme that flashed the readout into the card. + rgba = flash_color(self._widget_id or self._title, idx, rgba, primary()) # Big, centred class name. `set_window_font_scale` was removed # upstream; the modern API is `push_font(None, unscaled_base_size)` diff --git a/myogestic/widgets/training/session_manager.py b/myogestic/widgets/training/session_manager.py index 3ca839cc..042ea1fd 100644 --- a/myogestic/widgets/training/session_manager.py +++ b/myogestic/widgets/training/session_manager.py @@ -7,7 +7,14 @@ from imgui_bundle import portable_file_dialogs as pfd from myogestic.contracts import TrainingData -from myogestic.widgets.common import PALETTE, panel_header, pop_selected, push_selected +from myogestic.widgets.common import ( + PALETTE, + muted, + panel_header, + pop_selected, + primary, + push_selected, +) from myogestic.widgets.training._session_state import ( SessionWidgetState, add_recorded_session, @@ -169,7 +176,7 @@ def render_session_rows( row["selected"] = checked imgui.same_line() imgui.text_colored( - imgui.ImVec4(0.93, 0.95, 0.98, 1.0), + primary(), f"{row.get('date_str', row['name'])} · {row.get('streams_str', '')}", ) render_label_counts(row, class_names) @@ -183,7 +190,7 @@ def render_label_counts(row: dict, class_names: list[str] | None) -> None: ) if named_counts: imgui.same_line() - imgui.text_colored(imgui.ImVec4(0.55, 0.58, 0.62, 1.0), "·") + imgui.text_colored(muted(), "·") sess_names = row.get("class_names") or class_names for ci, n in named_counts: imgui.same_line() @@ -199,6 +206,6 @@ def render_label_counts(row: dict, class_names: list[str] | None) -> None: if unlabeled: imgui.same_line() imgui.text_colored( - imgui.ImVec4(0.55, 0.58, 0.62, 1.0), + muted(), f"· unlabeled:{unlabeled}", ) diff --git a/tests/test_visual_language.py b/tests/test_visual_language.py new file mode 100644 index 00000000..fd618e70 --- /dev/null +++ b/tests/test_visual_language.py @@ -0,0 +1,107 @@ +"""Enforce the mechanically-decidable rules of `docs/concepts/visual-language.md`. + +Only the parts a machine can decide honestly live here. Whether a cue *means* +the right thing, whether a label reads as plain language, whether a comparison +needs a shared range — those stay code review. A linter that guesses intent +produces noise and gets switched off. + +Both rules below were written against real drift: `RawSignalViewer` rendered as +stock ImPlot for want of one `ensure_implot_style()` call, and 19 hardcoded +colours had accumulated across 8 widget files, several of which (a near-white +label, a dark pill) were invisible or wrong on the light theme. + +The check is AST-based, not textual: a docstring in `_state.py` mentions +`begin_plot`, and a grep-based version flagged it. +""" + +from __future__ import annotations + +import ast +import pathlib + +import pytest + +_ROOT = pathlib.Path(__file__).resolve().parent.parent +_WIDGETS = _ROOT / "myogestic" / "widgets" + +#: The design layer itself — the one place colour literals belong. Everything +#: else must go through a token (`SUCCESS`, `PILL_BG`, …) or read a theme slot +#: (`muted()`, `primary()`, `hairline()`, `imgui.get_style().color_(...)`). +_DESIGN_LAYER = {"myogestic/_theme.py", "myogestic/widgets/common.py"} + + +def _py_files() -> list[pathlib.Path]: + return sorted(p for p in (_ROOT / "myogestic").rglob("*.py")) + + +def _rel(path: pathlib.Path) -> str: + return path.relative_to(_ROOT).as_posix() + + +def _tree(path: pathlib.Path) -> ast.Module: + return ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + + +def _calls(tree: ast.Module) -> list[ast.Call]: + return [n for n in ast.walk(tree) if isinstance(n, ast.Call)] + + +def _call_name(node: ast.Call) -> str: + """``implot.begin_plot(...)`` -> ``"implot.begin_plot"``; ``muted()`` -> ``"muted"``.""" + f = node.func + if isinstance(f, ast.Attribute) and isinstance(f.value, ast.Name): + return f"{f.value.id}.{f.attr}" + if isinstance(f, ast.Attribute): + return f.attr + if isinstance(f, ast.Name): + return f.id + return "" + + +@pytest.mark.parametrize("path", _py_files(), ids=_rel) +def test_no_hardcoded_colours_outside_the_design_layer(path: pathlib.Path): + """`ImVec4` built from bare numbers is a colour the theme cannot reach. + + A literal tuned on the dark theme goes invisible on the light one — that is + exactly how `_MUTED = ImVec4(0.65, ...)` and a near-white session label got + in. Colour components read from another colour (``off.x``, ``c[0]``, + ``PALETTE[i]``) are fine: those already come from a token or a theme slot. + """ + if _rel(path) in _DESIGN_LAYER: + return + offenders = [ + f"{_rel(path)}:{node.lineno}" + for node in _calls(_tree(path)) + if _call_name(node) == "imgui.ImVec4" + # All-constant numeric args == a colour typed by hand. A single scalar + # (ImVec2-style sizes are a different call) still counts if it's ImVec4. + and node.args + and all(isinstance(a, ast.Constant) and isinstance(a.value, (int, float)) for a in node.args) + ] + assert not offenders, ( + "Hardcoded colour literal(s) outside the design layer:\n " + + "\n ".join(offenders) + + "\n\nUse a token from myogestic.widgets.common (SUCCESS / DANGER / PILL_BG / " + "CONSOLE_BG …) or read the theme (muted(), primary(), hairline()). " + "See docs/concepts/visual-language.md. If the colour is deliberately fixed in " + "both themes, name it in common.py — do not inline it here." + ) + + +@pytest.mark.parametrize("path", _py_files(), ids=_rel) +def test_plot_widgets_apply_the_app_plot_style(path: pathlib.Path): + """Any module that opens an ImPlot plot must also style it. + + Without `ensure_implot_style()` the plot keeps ImPlot's stock look — chart + border, opaque background, heavy grid — and stops reading as part of the + app. `RawSignalViewer` shipped that way until this test was written. + """ + tree = _tree(path) + names = {_call_name(n) for n in _calls(tree)} + if "implot.begin_plot" not in names: + return + assert "ensure_implot_style" in names, ( + f"{_rel(path)} calls implot.begin_plot() without ensure_implot_style(). " + "Call it at the top of the widget so the plot matches the app " + "(see docs/concepts/visual-language.md)." + ) From 787b1a8441834f9987de674a230ef71f9b9c17b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Raul=20C=2E=20S=C3=AEmpetru?= Date: Fri, 24 Jul 2026 20:45:38 +0200 Subject: [PATCH 4/4] fix(viewer): make the chrome toggle swap its glyph like every other toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codebase consistently swaps a toggle's icon to show what clicking will do — PLAY/PAUSE on transport, ANGLES_DOWN/ARROW_DOWN on log autoscroll, the expand/collapse arrows on pop-out. The panel chrome toggle showed BARS in both states, so it read as a one-shot action rather than a live toggle. It now folds away under ANGLES_UP and comes back under BARS ("reveal a hidden menu"). Documents the rule and the established glyph meanings in visual-language.md, since one-glyph-per-meaning was a real convention that had never been written down — which is how the inconsistency got in. --- docs/concepts/visual-language.md | 21 +++++++++++++++++++++ myogestic/widgets/signals/viewer.py | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/concepts/visual-language.md b/docs/concepts/visual-language.md index 2ae164bd..d6538cb3 100644 --- a/docs/concepts/visual-language.md +++ b/docs/concepts/visual-language.md @@ -59,6 +59,27 @@ button caught mid-press. **Any control with a sticky on/off state uses it** — Inline actions that sit in a row of pills (`All` / `None` / `Invert` / `Edit…`) use `imgui.small_button` — a full-height `button` sits out of line with them. +## Icons + +One glyph per meaning, and the glyph **shows what clicking will do**, not what the state currently +is. A toggle therefore swaps its icon: `PLAY`↔`PAUSE` on transport, `ANGLES_DOWN`↔`ARROW_DOWN` on +log autoscroll, the expand↔collapse arrows on pop-out, `ANGLES_UP`↔`BARS` on panel chrome. A button +whose icon never changes reads as a one-shot action. + +Established meanings — reuse them rather than picking a near-synonym: + +| Glyph | Means | +| --- | --- | +| `ARROWS_ROTATE` | re-fetch live state: rescan, reconnect, refresh | +| `ROTATE_LEFT` | reset back to defaults | +| `BROOM` | clear accumulated content (a log) | +| `UP_RIGHT_AND_DOWN_LEFT_FROM_CENTER` / its inverse | pop out to a window / dock back | +| `BARS` | reveal a hidden menu | +| `TERMINAL` | program output | + +Icon plus label is `f"{icon} Label"` — two spaces, so the glyph doesn't crowd the text. Header +actions (`panel_header_button`) are icon-only. + ## Plots Call `ensure_implot_style()` at the top of any plot widget. Plots then read as part of the app: diff --git a/myogestic/widgets/signals/viewer.py b/myogestic/widgets/signals/viewer.py index cdcd9c7c..093892d1 100644 --- a/myogestic/widgets/signals/viewer.py +++ b/myogestic/widgets/signals/viewer.py @@ -163,7 +163,7 @@ def ui(self, ctx: Context) -> None: toggled = panel_header_button( self._title or f"SIGNAL · {active_stream}", fa.ICON_FA_CHART_LINE, - fa.ICON_FA_BARS, + fa.ICON_FA_ANGLES_UP, # fold away; ≡ below brings the menu back tooltip="Hide title, controls, channel bar and footer", ) else: