Signal viewer: channel_scope — a panel that actually owns its channels#19
Merged
Conversation
`initial_channels` only ever seeded a selection, so a per-electrode-grid panel
was scoped in appearance only: All enabled every channel in the stream, [Edit…]
listed every grid, and the count read N/320 rather than N/64. `channel_scope`
is the hard restriction — the columns a panel may EVER show.
It bounds the seed, All/None/Invert, the N/total count, the grid selector,
shift-click ranges and rubber-band drags, and it forms part of the selection
cache key so a differently-scoped viewer sharing a widget_id cannot inherit
another's selection. `None` stays unrestricted, so nothing changes for a
single-panel viewer.
Codex reviewed the plan and blocked it; each of its findings was verified in
source before being accepted, and four were real:
- resolve_enabled returns early on an unchanged key, so enforcing the scope
only while seeding/restoring would let anything that mutated v.channels in
between escape it. It now clamps on the fast path too.
- Invert went through reduce_selection's XOR, which PRESERVES out-of-scope
members (they aren't in the target set). It is now a complement within scope.
- Shift-click selected a raw numeric range, so a sparse scope {0,2,4}
shift-clicked 0->4 pulled in 1 and 3. The span is filtered through the
allowed set.
- An active-key change reset last_clicked but not the armed drag, so a drag
spanning a stream/scope switch applied a stale snapshot.
Also from that review: an explicit scope that matches nothing now renders "no
channels in scope" instead of falling back to every channel — a silent widening
would defeat the point of scoping a panel. resolve_initial keeps its signature
and takes a keyword-only scope, so the policy runs *inside* the scope; without
that, a 256..319 scope would seed "the first 16 of 320" and intersect to the
empty set, leaving a dead panel. Out-of-scope grid cells are nulled rather than
dropped, which preserves a grid's shape and — since rect_to_channels skips None
— makes drag selection incapable of reaching outside the scope. Scoped columns
no grid covers are collected into a trailing "other" grid so everything All can
select is also individually toggleable. The constructor snapshots the scope,
since an Iterable may be a generator that per-frame resolution would exhaust.
RaulSimpetru
changed the base branch from
feat/viewer-scaling-and-panels
to
main
July 25, 2026 05:27
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.
Stacked on #18 —
channel_scopebuilds on thewidget_idmulti-panel work, so this targetsthat branch rather than
main. Review/merge #18 first.Closes the known gap #18 called out:
initial_channelsseeds a selection, it does not restrictone. A per-electrode-grid panel (Quattrocento
IN1…IN5, 64 ch each of a 320-ch stream) wastherefore scoped in appearance only — until the user touched it:
[Edit…]listed every grid, not just the panel'sN/320rather thanN/64SignalViewer(channel_scope=…)is the hard restriction: the columns a panel may ever show. Itbounds the seed, All/None/Invert, the count, the grid selector, shift-click ranges and rubber-band
drags, and it forms part of the selection cache key so a differently-scoped viewer sharing a
widget_idcan't inherit another's selection.None(default) is unrestricted — nothing changesfor a single-panel viewer.
Bugs found while reviewing the plan
Codex reviewed the design and blocked it. Each finding was verified in source before being
accepted; four were real and would otherwise have shipped:
resolve_enabledreturns early on an unchanged key, so enforcing the scope only whileseeding/restoring would let anything that mutated
v.channelsin between escape it. It nowclamps on the fast path too.
reduce_selection's XOR, which preserves out-of-scope members (theyaren't in the target set). It is now a complement within the scope.
{0,2,4}shift-clicked 0→4 pulledin 1 and 3. The span is filtered through the allowed set.
last_clickedbut not the armed drag, so a drag spanning astream/scope switch applied a stale snapshot.
Design notes
rather than falling back to every channel — a silent widening would defeat the point of scoping
a panel. (
normalize_layout's forgiving fallback stays right for malformed stream metadata;it's wrong for an explicit caller constraint.)
resolve_initialkeeps its signature and takes a keyword-onlyscope, so the default policyruns inside the scope. Without that, a 256‥319 scope would seed "the first 16 of 320" and
intersect to the empty set — a dead panel.
shape and — since
rect_to_channelsskipsNone— drag selection is structurally incapable ofreaching outside the scope.
"other"grid, soeverything All can select is also individually toggleable.
Iterableadmits a generator, which per-frameresolution would exhaust.
channel_scopealso drives the default selection, so a 64-channel scope opens on its first 16unless
initial_channelsis passed too — the multi-grid example passes both.Notes
scopes, the 256‥319 high-offset seed, fast-path clamping, scope-fingerprint cache keys,
sparse shift-click, nulled-grid drags, partial metadata coverage, generator stability, and a
channel_scope=Noneregression guard.tests/test_stream_lsl.py::test_stream_reconnect_swaps_buffers_atomicallyremains flaky underfull-suite load (LSL multicast contention) and passes in isolation — pre-existing.