Signal viewer: stable y-scaling, width-relative detail, multi-panel layouts — plus a written visual language#18
Merged
Conversation
…youts
A signal-viewer pass covering three things: how the axis behaves, how much
gets drawn, and how several panels compose.
Scaling
- Auto no longer hands the axis to ImPlot's per-frame auto_fit. The range
snaps out instantly to contain a new peak (never clipped) but contracts
over ~5 s, so a variable signal in a small window stops zoom-jittering.
Per-channel mode gets the same easing — its lane ranges no longer breathe —
and its range scan is vectorised (~15x cheaper at 256 channels).
- Per-Ch is now a scaling *basis*, not a mode that greys out Auto/Manual.
Per-channel Manual freezes each lane's range so a weakening channel stays
visible against its captured reference; Rescale re-fits and locks; Gain is
live in per-channel Manual and inert in per-channel Auto.
- Range fitting rejects transients shorter than a budget ("Reject <", default
20 ms) by *duration* rather than amplitude, so a brief movement artifact no
longer blows up the scale and dwarfs the EMG. Mode-aware (rectify/rms_env
pin the lower bound to 0), per-channel then unioned for the shared axis, and
cheap enough for the per-frame path (~11 ms at 256 ch). 0 ms restores min/max.
Detail
- The fixed "Point cap" slider (100-10000 pts) becomes "Detail": draw density
relative to the plot width, shown as a percentage of full. The old cap fought
the width-derived target — min(n_pixels, width*3) — so the top half of its
range was inert on a typical plot and the default under-resolved wide ones.
n_pixels survives as an optional hard-cap override (default: no cap).
Layout
- Several viewers can share one stream. State and ImGui ids now key off a new
widget_id (defaulting to stream_name) rather than the stream, so one panel
per electrode grid works, each keeping its own channels, scale, filter and
pause; previously every SignalViewer("emg") resolved to one state and
rendered identically. title names a panel, show_controls opens it collapsed.
- The panel chrome — title, control menu, channel bar, footer — collapses
behind the header toggle, leaving a tiled panel almost all plot. Collapsed,
the toggle shrinks to a bare icon so there is always a way back.
- The channel-grid selector tiles several grids near-square (6 -> 3x2) instead
of one tall stack, and opens sized to the tiling.
- Heatmap.ui gains vrange, an explicit colour range. Several heatmaps must
share one to be comparable: with per-instance autoscaling a quiet electrode
array and a loud one render identically.
…to it The theme was a real, deliberate system — Instrument Serif / IBM Plex Mono, the tint+underline "this is on" cue, borderless transparent plots, tab10 for series identity — but it lived only in the code, so new controls drifted from it. concepts/visual-language.md states it: type, colour, panel headers, state cues, plot styling, unit formatting, pop-out vs collapse, and widget identity. It sits beside design-principles.md, which stays the code contract. Two controls that had drifted: - The panel chrome toggle now carries push_selected/pop_selected when collapsed, like every other sticky toggle (Edit…, Manual). A bare icon over a plot read as an unrelated action rather than a live state. - "Reject <" becomes "Artifact", with the slider formatting the threshold as "< 20 ms". A label should say what the control does, spelled out; a truncated operator symbol is jargon to whoever is running the session. Two other suspected drifts did not survive checking, and are left alone: panel titles already pass through panel_header's uppercase/muted/icon/ellipsis treatment, and %/x/s are each correct for the quantity they label — that was a missing written rule, now in the doc, not a defect.
Writing the visual language down made it checkable, and the check found real bugs. Nineteen colours were typed as literals across eight widget files, so they could not follow the active theme: the session-manager label and the raw viewer's footer were near-white (washed out on the light theme), the channel-grid hover outline was white-on-white and therefore invisible, and the prediction readout flashed *toward white* — into the card it sits on. Those now read the theme through new common.py helpers (muted, primary, hairline) or an existing token. Colours deliberately fixed in both themes are named rather than inlined: CONSOLE_BG / CONSOLE_TEXT for the terminal surface (a terminal stays dark on a light desktop) and PILL_BG / ON_PILL_TEXT for the status badge. ProcessLauncher's hand-rolled red/green become the shared DANGER / SUCCESS, so Stop/Launch match status everywhere else. Separately, RawSignalViewer called implot.begin_plot without ensure_implot_style(), so it rendered with stock ImPlot chrome — border, opaque background, heavy grid — instead of the app's plot style. That is the bug the second rule was written to catch, and it had shipped. tests/test_visual_language.py enforces both rules, with no allowlist. It is AST-based, not textual: a docstring in _state.py mentions begin_plot and a grep version flagged it. Both rules were verified to fail when a violation is reintroduced. Rules that need judgement — is this the right cue, does this comparison need a shared range — are deliberately left to review. CLAUDE.md points agents at both contracts before they touch a widget, which is what would have prevented the drift in the first place.
…oggle
The codebase consistently swaps a toggle's icon to show what clicking will do
— PLAY/PAUSE on transport, ANGLES_DOWN/ARROW_DOWN on log autoscroll, the
expand/collapse arrows on pop-out. The panel chrome toggle showed BARS in both
states, so it read as a one-shot action rather than a live toggle. It now folds
away under ANGLES_UP and comes back under BARS ("reveal a hidden menu").
Documents the rule and the established glyph meanings in visual-language.md,
since one-glyph-per-meaning was a real convention that had never been written
down — which is how the inconsistency got in.
RaulSimpetru
added a commit
that referenced
this pull request
Jul 25, 2026
Cut 2.4.0 — the signal-viewer scaling/detail/multi-panel work (#18) and the channel_scope follow-up (#19). New user-facing capability with no breaking changes, hence a minor bump. Highlights: the y-axis eases instead of jittering and ignores artifacts by duration; "Point cap" becomes the width-relative "Detail" control; one stream can be shown through several panels (widget_id) each owning its own channels (channel_scope); Heatmap gained a shared colour range. The visual language that had only ever lived in the code is now written down in docs/concepts/visual-language.md and two of its rules are enforced by tests — which caught RawSignalViewer rendering as stock ImPlot and 19 hardcoded colours that could not follow the light theme.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three things that grew out of each other: making the y-axis stop jittering, making the
"how much to draw" control mean something, and making one stream viewable through several
panels. The last one exposed how much of the visual language lived only in the code, so it
is now written down and partly enforced.
Scaling
auto_fit. The range snaps outinstantly to contain a new peak (never clipped) but contracts over ~5 s, so a variable
signal in a small window stops zoom-jittering. Per-channel mode gets the same easing — its
lane ranges no longer breathe — and its range scan is vectorised (~15× cheaper at 256 ch).
Manual freezes each lane's range so a weakening channel stays visible against its captured
reference; Rescale re-fits and locks; Gain is live in per-channel Manual, inert in Auto.
20 ms), because a 10 ms artifact and a 10 ms real event are indistinguishable by amplitude
alone. Mode-aware, per-channel then unioned, ~11 ms at 256 ch.
0 msrestores plain min/max.Detail
The fixed "Point cap" slider (100–10000 pts) becomes "Detail": draw density relative
to the plot width, shown as a percentage of full. The old cap fought the width-derived target
(
min(n_pixels, width × 3)), so the top half of its range was inert on a typical plot and thedefault under-resolved wide ones.
n_pixelssurvives as an optional hard-cap override(default: no cap).
Multi-panel
widget_id(defaulting to
stream_name) rather than the stream, so one panel per electrode grid works,each keeping its own channels, scale, filter and pause. Previously every
SignalViewer("emg")resolved to one state and rendered identically. This also restores design-principle 8 —
state keyed by widget identity, not by the data it happens to show.
leaving a tiled panel almost all plot.
Heatmap.ui(vrange=…)— an explicit colour range. Several heatmaps must share one to becomparable; with per-instance autoscaling a quiet electrode array and a loud one render
identically.
Visual language, and two bugs it caught
docs/concepts/visual-language.mdwrites down what_theme.py/widgets/common.pyalreadydid — type stack, the tint+underline selection cue, panel headers, plot styling, units, icon
meanings, pop-out vs collapse, widget identity. It sits beside
design-principles.md, whichstays the code contract.
Writing it down made parts of it checkable, and
tests/test_visual_language.py(AST-based, noallowlist) found real bugs:
RawSignalViewerrendered as stock ImPlot — chart border, opaque background, heavy grid— for want of one
ensure_implot_style()call.on the light theme specifically: a near-white session label, a white-on-white channel-grid
hover outline, and a prediction readout that flashed toward white — into the card it sits
on. They now read the theme (
muted()/primary()/hairline()) or a named token.Colours deliberately fixed in both themes (console surface, status pill) are named in
common.pyrather than inlined.ProcessLauncherused its own red/green instead of the sharedDANGER/SUCCESS.(
PLAY↔PAUSE, autoscroll, pop-out), so it read as a one-shot action.Rules needing judgement — is this the right cue, does this comparison need a shared range —
are deliberately left to review.
CLAUDE.mdpoints agents at both contracts before they touch a widget.Notes
reintroduced.
tests/test_stream_lsl.py::test_stream_reconnect_swaps_buffers_atomicallyis flaky underfull-suite load (LSL multicast contention) and passes in isolation — pre-existing.
initial_channelsseeds a viewer's channels but does not restrict them, soin a tiled per-grid layout "All" still enables every channel and
[Edit…]lists every grid.A real
channel_scopeparameter is the fix; left for a follow-up.