diff --git a/CHANGELOG.md b/CHANGELOG.md index bc875d4c..0592f3de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,90 @@ 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 (**"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 + 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). + +### 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/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..d6538cb3 --- /dev/null +++ b/docs/concepts/visual-language.md @@ -0,0 +1,132 @@ +# 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. + +## 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: +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/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/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..55e23980 100644 --- a/myogestic/widgets/signals/_controls.py +++ b/myogestic/widgets/signals/_controls.py @@ -1,18 +1,28 @@ 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 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, 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 +165,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 +234,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 +271,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 +282,26 @@ 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("Artifact") + imgui.same_line() + imgui.set_next_item_width(-1) + # 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(): + 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 +462,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 +504,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, @@ -619,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) @@ -628,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 5296c4db..535e257a 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 @@ -7,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: @@ -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: @@ -277,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) @@ -396,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 | " @@ -432,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/_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/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/signals/viewer.py b/myogestic/widgets/signals/viewer.py index 7cae56aa..093892d1 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, pop_selected, push_selected 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,46 @@ 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_ANGLES_UP, # fold away; ≡ below brings the menu back + 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: + v.show_controls = not v.show_controls if stream is None: imgui.text(f"{active_stream}: not found") return @@ -132,7 +184,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 +196,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 +214,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 +249,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 +263,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/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/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: 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_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)." + ) 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."""