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
84 changes: 84 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
47 changes: 47 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/concepts/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
132 changes: 132 additions & 0 deletions docs/concepts/visual-language.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 37 additions & 0 deletions myogestic/widgets/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]] = {}


Expand Down
10 changes: 3 additions & 7 deletions myogestic/widgets/panels/log_box.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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())
Expand Down
6 changes: 3 additions & 3 deletions myogestic/widgets/panels/process_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand Down
Loading