scope: full-screen figures, second windows, gated measurements, per-configuration events

A batch of view and instrument work, with the probes and docs for each.

- **Full screen per figure row**: a toggle on each row's top-right corner, revealed by
  pointer proximity (`kMaximizeRevealPx` around its placed rect, per row, read from an
  overlay-level motion controller so a revealed button under the pointer cannot flicker);
  the plot menu offers the same as *Full screen this figure* / *Restore the layout*. The
  arrangement is saved and restored through `RowState`, so nothing plotted moves. A layout
  of one figure carries no toggle.
- **Second window**: `open_scope_window()` copies a figure (cells, width shares, signals in
  their colours) or opens an empty one, with its own `RowSet` and `PlotArea`; the main
  window's tick binds, acquires and repaints it, mirroring the time window and pin. Entries:
  the plot menu's *Open this plot in a new window* and the overflow menu's *New scope window*.
- **Layout editor from the plot**: the plot menu gains *Edit the layout...* / *Done editing
  the layout*, flipping the app's one toggle.
- **Measurements gate**: `MeasureOptions::gate` restricts a measurement to the held/pause
  snapshot (live falls back to the live ring), persisted with the session and shown in the
  measure dialog.
- **Crosshair axis badges**: a preference (`crosshair_axis_labels`, on by default) and the
  plotter's badges where the lines meet the axes, in the axes' own formats; the plot menu
  and the probe read them.
- **Event cache per configuration**: `EventStore` is keyed `<source>/<configuration>/`,
  `retarget_event_store()` follows every configuration switch, and a pre-configuration cache
  (loose files) is adopted into the configuration's folder on first start.
- **GTK4 lifecycle**: the main window's handlers move from the non-existent `"destroy"`
  signal to `close-request`; scope windows are closed with it and the application quits
  explicitly (the exit run's hang). The exit probe closes with `gtk_window_close()`.
- Probes: `[maximize-probe]` (single-figure rule and the corner reveal), `[scope-probe]`,
  `[split-probe]` cell crosshair leg, `[prefs-probe]` store keying, `[configs]` store key,
  `[rowmenu]` layout-editor entry; docs, help pages, README and AGENT.md updated.

Also fixes a probe-only use-after-free: `toggle_maximize_figure_row` replaces the figure
object, so the area must be re-pointed (the button's path does; the probe's raw call did not).
This commit is contained in:
Martino Ferrari committed 2026-11-05 17:58:59 +01:00
1 parent bfe13630f9
commit bdf0ff7ddc
39 files changed
+2267 -136

No files matched your search

+41
View File
@@ -70,6 +70,47 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
compares with a relative tolerance, because clamping to exactly `window_s_` can leave the
group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a
clamp.
- **A row names its channel: number and signal, from one resolver.** The pane's row reads
`N. signal · type`, the dock's and the strip's lines prefix the same number to the type,
the signal's badge carries it, and the measurement dialog's signal dropdown numbers its
entries with it -- all through `measure_channel_number()`, which is the position of the
signal's channel in the model's channel list (the acquired channels in stream order), or 0
when the signal is not acquired. The readouts rule cuts both ways here: every view shows the
same number because every view asks the one function, and a view that derived a number by
its own walk of the channel list would be the second spelling that drifts. 0 is a state, not
a failure -- the unacquired signal's row is already grey -- so the readers *omit* the number
rather than printing a 0. Renumbering on every enable, disable and CONFIG is inherent (the
number answers "which active channel is this", which is what a rebuild renumbers), so a
generation change rebuilds the pane as well as the signal list.
- **The gate is a per-measurement option, and one band shared by everyone who uses it.**
`MeasureOptions::gate` is set per measurement in its dialog; every gated measurement reads
only inside the one shaded band -- a plotter `Region`, violet, its two bounds the gate's own
pair of instruments, not the cursors, which stay what the cursors pane reads -- and the band
is *shown exactly while some measurement is gated*, derived at `rebuild_measures_pane()`,
because every change of the measure set (an add, an edit, a delete, a session restore)
passes through there. Ungated measurements are keyed away from gated ones
(`MeasureWindow`'s key is `(channel, fc, gated)`), so a gated measurement costs one more
walk and changes no ungated number.
The bounds live on the `RowSet` in **display x** (`gate_x0()`/`gate_x1()`), the cursors' own
space, and that is the point: a live axis is newest-relative, so a band at `[-0.3, -0.1]`
keeps meaning "300 ms to 100 ms ago" while the data flows under it, and a held event's
stamps are edge-relative, so there the band names a fixed slice of the frozen frame. Either
way it stays where it was put, which is what makes it a gate and not a second window.
`update_measures()` clips the pane's window (viewport live, pin pinned) into the band once,
and each gated window walks the clip: every statistic, mark and "it moved" booking below
describes the gated slice through that one expression. A band entirely off the view clips to
a zero span and the pane reads the same `--` any empty window gives. Because a band that
moves can change what the *gated* measurements describe without the viewport or pin moving,
the "it moved" test asks the band as well (`measure_gate_window`, booked every pass). While
a region drag runs, the tick adopts the bounds out of the dragged row's plot
(`sync_gate(drag_row)` -- the `sync_cursors()` contract: read only while a gesture holds the
instrument, because outside one the read is our own echo) and mirrors them to every other
row; the drag's *end* saves the session, because the bounds are session state
(`gate_x0`/`gate_x1`, and each measure's `gate` flag in its `[[measure]]` section) and a
drag that is not saved is a session that lies about where the gate is. `[gate-probe]` is the
reading: the gated average differs from the ungated one on the same signal, the booked gate
window is the band moved onto the stream's clock, the band is still where it was put a
second later while `s0=` has moved under it, and a restored session brings both back.
- **A measurement must not read its window raw.** Both passes go through
`plot::data::MinMaxPyramid`, which the producer already maintains at amortised O(1) -- so the
pane inherits the summary of the new samples rather than computing one, which is what the