Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- **Signal viewer — `channel_scope` restricts a panel to its own channels.** `initial_channels`
only ever *seeded* a selection, so a per-electrode-grid panel stayed scoped only until the user
touched it: **All** enabled every channel in the stream, `[Edit…]` listed every grid, and the
count read `N/320` instead of `N/64`. `SignalViewer(channel_scope=…)` is the hard restriction —
the columns a panel may *ever* show. It bounds the seed, All/None/Invert, the count, the grid
selector, shift-click ranges and rubber-band drags, and forms part of the selection cache key.
`None` (default) is unrestricted; an explicit scope matching no valid column renders
"no channels in scope" rather than silently widening back to the whole stream. Note it also
drives the default selection, so a 64-channel scope opens on its first 16 unless
`initial_channels` is passed too.
- **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.
Expand Down
67 changes: 61 additions & 6 deletions myogestic/widgets/signals/_channel_grid.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,26 @@ def _null_invalid_cells(cells: list[list[int | None]], n_channels: int) -> list[
return result


def normalize_layout(channel_grids: list[ChannelGrid] | None, n_channels: int) -> list[ChannelGrid]:
def resolve_scope(channel_scope: Iterable[int] | None, n_channels: int) -> list[int]:
"""Resolve the columns a viewer may **ever** show, in caller order.

``None`` means unrestricted — every channel. An explicit iterable is
clamped to ``[0, n_channels)`` and de-duplicated (first-seen wins), and
**may resolve to empty**: an explicit constraint that matches nothing is
honoured rather than quietly widened back to the whole stream, which would
defeat the point of scoping a panel. Order is preserved because the default
selection policy takes a prefix of it (see [`resolve_initial`][]).
"""
if channel_scope is None:
return list(range(n_channels))
return _dedupe_in_range(list(channel_scope), n_channels)


def normalize_layout(
channel_grids: list[ChannelGrid] | None,
n_channels: int,
scope: list[int] | None = None,
) -> list[ChannelGrid]:
"""Validate `channel_grids` against `n_channels`, never raising.

Out-of-range and duplicate column indices are dropped (first-seen wins).
Expand All @@ -96,22 +115,47 @@ def normalize_layout(channel_grids: list[ChannelGrid] | None, n_channels: int) -
valid columns are dropped entirely. If nothing survives — `channel_grids`
is ``None``/empty, or every grid was fully invalid — falls back to a
single auto-shaped grid labeled ``"all"`` spanning every channel.

`scope` (from [`resolve_scope`][]) restricts the layout to a viewer's own
columns: out-of-scope cells are **nulled, not dropped**, so a rectangular
grid keeps its physical shape and — because [`rect_to_channels`][] skips
``None`` — a rubber-band drag cannot reach outside the scope. Grids left
empty are removed, and any scoped column no grid covers is collected into a
trailing auto-shaped ``"other"`` grid, so everything **All** can select is
also individually toggleable. ``None`` leaves the layout unrestricted.
"""
fallback = [ChannelGrid("all", auto_shape(list(range(n_channels))))]
fallback_columns = list(range(n_channels)) if scope is None else list(scope)
fallback = [ChannelGrid("all", auto_shape(fallback_columns))] if fallback_columns else []
if not channel_grids:
return fallback

scope_set = None if scope is None else set(scope)
result: list[ChannelGrid] = []
for grid in channel_grids:
valid_columns = _dedupe_in_range(grid.columns, n_channels)
if scope_set is not None:
valid_columns = [c for c in valid_columns if c in scope_set]
if not valid_columns:
continue
if _is_rectangular(grid.cells):
new_cells = _null_invalid_cells(grid.cells, n_channels)
if scope_set is not None:
new_cells = [
[c if c is not None and c in scope_set else None for c in row]
for row in new_cells
]
else:
new_cells = auto_shape(valid_columns)
result.append(ChannelGrid(grid.label, new_cells))

if scope_set is not None and result:
# Anything in scope that no surviving grid covers would be selectable via
# All yet impossible to toggle in the grid window — give it a home.
covered = {c for g in result for c in g.columns}
missing = [c for c in scope if c not in covered]
if missing:
result.append(ChannelGrid("other", auto_shape(missing)))

return result if result else fallback


Expand Down Expand Up @@ -163,17 +207,28 @@ def resolve_initial(
initial_channels: Iterable[int] | None,
n_channels: int,
layout: list[ChannelGrid],
*,
scope: list[int] | None = None,
) -> set[int]:
"""Resolve the widget's initial selection.

``None`` selects every channel when `n_channels` is small (``<= 32``),
otherwise the first ``min(n_channels, 16)``. An explicit iterable is
clamped to the valid ``[0, n_channels)`` range. `layout` is accepted for
interface symmetry with future policies but isn't consulted yet.

`scope` (from [`resolve_scope`][]) makes the whole policy relative to a
viewer's own columns: the size test and the 16-channel prefix both run over
the scope, and an explicit selection is clamped to it. This has to be built
in rather than filtered afterwards — for a scope of columns 256‥319 the
unscoped policy would pick "the first 16 of 320", i.e. columns 0‥15, whose
intersection with the scope is **empty**, leaving a dead panel.
"""
del layout
columns = list(range(n_channels)) if scope is None else scope
if initial_channels is None:
if n_channels <= 32:
return set(range(n_channels))
return set(range(min(n_channels, 16)))
return {c for c in initial_channels if 0 <= c < n_channels}
if len(columns) <= 32:
return set(columns)
return set(columns[:16])
allowed = set(columns)
return {c for c in initial_channels if c in allowed}
53 changes: 43 additions & 10 deletions myogestic/widgets/signals/_controls.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,7 @@ def render_channel_controls(
stream: Stream,
v: ViewerState,
n_channels: int,
scope: list[int] | None = None,
) -> tuple[set[int], list[str] | None, int]:
"""Render the compact channel bar, and mutate `v.channels` from user input.

Expand All @@ -370,13 +371,19 @@ def render_channel_controls(
enabled = v.channels
ch_names = stream.info.channel_names if stream.info else None
channel_grids = stream.info.channel_grids if stream.info else None
layout = normalize_layout(channel_grids, n_channels)
if scope is None:
scope = list(range(n_channels))
layout = normalize_layout(channel_grids, n_channels, scope)
ui = _grid_ui.setdefault(stream_name, _GridUIState())
if ui.last_key != v.active_channels_key:
ui.last_clicked = -1
# Disarm any in-flight drag too: its snapshot and origin cell belong to
# the *previous* stream/scope, so finishing it here would apply a stale
# selection (or ch0) to the new one.
ui.drag = _DragSession()
ui.last_key = v.active_channels_key

render_channel_bar(stream_name, ui, enabled, n_channels)
render_channel_bar(stream_name, ui, enabled, scope)

hovered_ch = -1
if ui.show_grid:
Expand All @@ -394,25 +401,32 @@ def render_channel_bar(
stream_name: str,
ui: _GridUIState,
enabled: set[int],
n_channels: int,
scope: list[int],
) -> None:
"""Render the always-inline, one-line channel bar.

`Channels {enabled}/{total}` + the global All/None/Invert ops (what the
full-grid footer did before) + `[Edit…]`, which toggles the floating
grid window (`ui.show_grid`) — the grid itself never renders inline.

Every op is bounded by `scope` — the columns this viewer may show — so the
total, All and Invert describe the panel's own channels rather than the
whole stream's. An unscoped viewer passes every channel.
"""
imgui.text(f"Channels {len(enabled)}/{n_channels}")
imgui.text(f"Channels {len(enabled)}/{len(scope)}")
imgui.same_line()
if imgui.small_button(f"All##{stream_name}_bar_all"):
enabled.clear()
enabled.update(range(n_channels))
enabled.update(scope)
imgui.same_line()
if imgui.small_button(f"None##{stream_name}_bar_none"):
enabled.clear()
imgui.same_line()
if imgui.small_button(f"Invert##{stream_name}_bar_invert"):
new_enabled = reduce_selection(enabled, "invert", range(n_channels))
# Complement *within the scope*, not `reduce_selection`'s XOR: XOR keeps
# any out-of-scope member of `enabled` (it isn't in the target set), so
# inverting could smuggle a foreign channel back in.
new_enabled = set(scope) - enabled
enabled.clear()
enabled.update(new_enabled)
imgui.same_line()
Expand Down Expand Up @@ -471,6 +485,10 @@ def render_grid_window(
f"Channel selection — {stream_name}##{stream_name}_grid_window", True
)
hovered_ch = -1
# The layout is already scope-restricted by `normalize_layout`, so its own
# columns are exactly what may be selected — the bound a shift-click range
# has to respect.
allowed = {c for g in layout for c in g.columns}
try:
if visible:
# Tile the grids near-square (n_cols per row) instead of one tall
Expand All @@ -483,7 +501,7 @@ def render_grid_window(
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
stream_name, grid_idx, grid, enabled, ch_names, ui, cell, hovered_ch, allowed
)
imgui.end_group()
finally:
Expand Down Expand Up @@ -537,8 +555,13 @@ def render_grid(
ui: _GridUIState,
cell: float,
hovered_ch: int,
allowed: set[int] | None = None,
) -> int:
"""Render one grid's header + cells; returns the updated `hovered_ch`."""
"""Render one grid's header + cells; returns the updated `hovered_ch`.

`allowed` is forwarded to `render_cell` so a shift-click range cannot
escape this viewer's scope (``None`` = unrestricted).
"""
columns = grid.columns
total = len(columns)
sel = sum(1 for c in columns if c in enabled)
Expand Down Expand Up @@ -577,6 +600,7 @@ def render_grid(
ui,
cell,
hovered_ch,
allowed,
)

# Live rectangle update: recompute from the mouse-down snapshot every
Expand Down Expand Up @@ -679,8 +703,13 @@ def render_cell(
ui: _GridUIState,
cell: float,
hovered_ch: int,
allowed: set[int] | None = None,
) -> int:
"""Render one channel cell; returns the updated `hovered_ch`."""
"""Render one channel cell; returns the updated `hovered_ch`.

`allowed` bounds a shift-click range selection to the columns this viewer
may show (``None`` = unrestricted).
"""
imgui.invisible_button(f"##{stream_name}_g{grid_idx}_cell_{ch}", imgui.ImVec2(cell, cell))

is_on = ch in enabled
Expand Down Expand Up @@ -724,7 +753,11 @@ def render_cell(
io = imgui.get_io()
if io.key_shift and ui.last_clicked >= 0:
lo, hi = sorted((ui.last_clicked, ch))
new_enabled = reduce_selection(enabled, "add", range(lo, hi + 1))
# The span is numeric, but the selection must not be: a sparse scope
# (or a grid with holes) would otherwise pick up channels between the
# endpoints that this viewer may not show at all.
span = range(lo, hi + 1) if allowed is None else [c for c in range(lo, hi + 1) if c in allowed]
new_enabled = reduce_selection(enabled, "add", span)
enabled.clear()
enabled.update(new_enabled)
ui.drag.armed = False
Expand Down
26 changes: 22 additions & 4 deletions myogestic/widgets/signals/_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ class ViewerState:
# `resolve_enabled`. `None` until the first resolve. Also read by
# `_controls.py` to reset the toggle-grid's shift-click anchor on a
# stream/channel-count change.
active_channels_key: tuple[str, int] | None = None
active_channels_key: tuple[str, int, tuple[int, ...] | None] | None = None
# Per-`(stream_key, n_channels)` selection cache so a `selectable`
# viewer that flips between streams restores each stream's own
# selection instead of sharing/resetting a single one. Populated by
Expand Down Expand Up @@ -456,6 +456,7 @@ def resolve_enabled(
stream_key: str,
n_channels: int,
initial_channels: Iterable[int] | None = None,
scope: list[int] | None = None,
) -> set[int]:
"""Resolve the enabled channel set from persistent viewer state.

Expand All @@ -481,9 +482,24 @@ def resolve_enabled(
one-shot hint. Returns the live `v.channels` set; safe because
nothing reads it again until the *next* frame, after which only
`render_channel_controls` mutates it.

`scope` (from `resolve_scope`) is the hard
restriction — the columns this viewer may *ever* show. It bounds the seed,
any restored selection, and the live set on **every** path, and it forms
part of the cache key so a differently-scoped viewer sharing a
``widget_id`` never inherits another's selection. ``None`` is unrestricted.
"""
key = (stream_key, n_channels)
# The scope fingerprint is part of the identity: state is keyed by `widget_id`,
# not by Python instance, so a re-created (or same-id, differently-scoped)
# viewer must not inherit a selection resolved under a different scope.
key = (stream_key, n_channels, None if scope is None else tuple(scope))
scope_set = None if scope is None else set(scope)
if v.channels_initialized and v.active_channels_key == key:
if scope_set is not None:
# Enforce on the fast path too: this returns before any of the seeding
# below, so intersecting only on restore would let anything that
# mutated `v.channels` in between escape the scope.
v.channels.intersection_update(scope_set)
return v.channels

first_ever = not v.channels_initialized
Expand All @@ -492,9 +508,11 @@ def resolve_enabled(

cached = v._channels_by_key.get(key)
if cached is not None:
v.channels = set(cached)
v.channels = set(cached) if scope_set is None else set(cached) & scope_set
else:
v.channels = resolve_initial(initial_channels if first_ever else None, n_channels, [])
v.channels = resolve_initial(
initial_channels if first_ever else None, n_channels, [], scope=scope
)

v.specs = []
v.channels_initialized = True
Expand Down
35 changes: 31 additions & 4 deletions myogestic/widgets/signals/viewer.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ def my_ui(ctx):
from imgui_bundle import imgui

from myogestic.widgets.common import panel_header_button, pop_selected, push_selected
from myogestic.widgets.signals._channel_grid import resolve_scope
from myogestic.widgets.signals._controls import (
render_channel_controls,
render_controls,
Expand Down Expand Up @@ -61,8 +62,20 @@ class SignalViewer:
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).
identically. Pair with ``channel_scope`` to give each its own channels,
and prefer a stable, unique string (grid labels can repeat).
channel_scope
The columns this viewer may **ever** show — a hard restriction, unlike
``initial_channels`` (which only seeds the opening selection). All /
None / Invert, the ``N/total`` count, the ``[Edit…]`` grid and
shift-click ranges are all bounded by it, so a per-electrode-grid panel
stays its own array however the user clicks. ``None`` (default) is
unrestricted; an explicit scope that matches no valid column renders
"no channels in scope" rather than quietly widening back to the whole
stream. Positional, so with ``selectable=True`` it is re-applied
(clamped) to whichever stream is shown. Note it also drives the default
selection: a 64-channel scope opens on its first 16 unless you pass
``initial_channels`` too.
title
Panel header text. Defaults to ``"SIGNAL · <stream>"`` — set it per
panel when several viewers share a stream, or every tile reads alike.
Expand Down Expand Up @@ -118,8 +131,13 @@ def __init__(
widget_id: str | None = None,
title: str | None = None,
show_controls: bool = True,
channel_scope: Iterable[int] | None = None,
) -> None:
self._stream_name = stream_name
# Snapshot the scope: `Iterable` admits a generator, and this is re-read
# every frame — a lazy one would be exhausted after the first. Order is
# kept because the default selection takes a prefix of it.
self._channel_scope = None if channel_scope is None else tuple(channel_scope)
self._widget_id = widget_id
self._title = title
self._show_controls = show_controls
Expand Down Expand Up @@ -191,7 +209,16 @@ def ui(self, ctx: Context) -> None:
# building the frame, so the frame's column slice and the plot loop's
# per-channel decimation only ever touch those columns.
n_channels = stream.info.n_channels
enabled = resolve_enabled(v, active_stream, n_channels, self._initial_channels)
# The scope is positional (column indices), so it applies to whichever
# stream is shown, clamped to that stream's width — never silently
# relaxed, which would break the "may ever show" contract.
scope = resolve_scope(self._channel_scope, n_channels)
if not scope:
imgui.text_disabled(
f"{active_stream}: no channels in scope (stream has {n_channels})"
)
return
enabled = resolve_enabled(v, active_stream, n_channels, self._initial_channels, scope)

# Channel bar at the top, above the plot. It reads/mutates `v.channels`
# (the same set `enabled` points at) and reports grid-hover — all
Expand All @@ -200,7 +227,7 @@ def ui(self, ctx: Context) -> None:
# 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)
_, _, hovered_ch = render_channel_controls(wid, stream, v, n_channels, scope)
v.last_hovered = hovered_ch

frame = build_signal_frame(stream, v, enabled)
Expand Down
Loading