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

+63 -4
View File
@@ -60,7 +60,8 @@ see [probe-readings.md](probe-readings.md).
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
runs **after `g_application_run` returns**, so the window and every widget in it are gone -- the pointer
is **freed memory**, and a critical is only
the lucky reading of it. A run that had built and destroyed other windows first —
`UDPSCOPE_PROBE_HELP_MS`, which opens the About and shortcut dialogs and then closes them — has had
that memory reused, and the same two calls
@@ -74,7 +75,26 @@ precedent, not an exception.
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
frames, trace bindings, panel sizes, status line in both states).
- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`,
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_ARM_TRIGGER_MS` clicks the
trigger toggle and then samples the **trigger status circle** once a second for four
seconds: an armed, waiting trigger reads `circle=flash-lit` / `circle=flash-dim` (the two
halves of the 1 Hz flash, in the phase the sample lands in), a latched event `circle=green`,
a disarm `circle=red`; the exit dump carries the last tick's state as `trigger circle:`
(`off` under `UDPSCOPE_EXIT_AFTER_MS`, whose own probe goes live before it dumps).
`UDPSCOPE_PROBE_CROSSHAIR_MS` hovers the pointer over row 0 through the adapter's own
controller and asserts the crosshair's two **axis badges** landed in their label strips
(`[crosshair-probe] active=1 ... y_ok=1 x_ok=1`), then flips the flag off and reads the
plot's back; the pixel-level contract is the plotter suite's. The preference behind the
flag is `[prefs-probe]`'s `xhair=` field. `UDPSCOPE_PROBE_MAXIMIZE_MS` arranges two
single-cell rows (sine, square) and exercises the one-click **full-screen** toggle through
the same `RowSet` call the button's handler makes — `[maximize-probe] before: rows=2 frows=2
row0=sine row1=square buttons=2; max: rows=1 frows=1 flag=1 row0=sine; back: rows=2
row0=sine row1=square flag=0` — where `buttons=` is the overlay's one toggle per figure row
and the `back` leg is the proof that a maximize moves no signal. `UDPSCOPE_PROBE_SCOPE_MS`
opens the **second window** twice, once copying figure 0 and once empty, and reads it back
a few frames later: `[scope-probe] count=2 copied='sine' bound=1 empty_ok=1 windows=1` —
`bound=1` is the main tick having bound the copy's rows to the same model, and `empty_ok=1`
the empty scope staying empty. `UDPSCOPE_PROBE_DISARM_MS` only
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
- `UDPSCOPE_PROBE_FORCE_MS` — the trigger panel's **Force** button: one acquisition at the present
@@ -234,13 +254,16 @@ precedent, not an exception.
window prices a 15 s one at nothing (four measurements over four channels read `35.69 ms` a
recompute and a 4.58 ms tick mean at a 15 s window against `6.58 ms`/1.34 ms for one window at
the same window).
- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
- `[measure-probe] N measurements on 'X' (channel C)` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
measurement of each statistical type (average, top, peak to peak, amplitude) on the first
*channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the
first signal of a stream is its timestamp, which is not acquired, and naming it adds four
measurements that all read `--` *and* makes a performance run look free (each bails before
collecting anything -- that is how the first baseline of the stall below read 0.4 ms).
The values are in the exit dump as `measurements: <type>=<value>`; on the fast mock
The values are in the exit dump as `measurements: N.signal <type>=<value>`, each numbered
with the signal's active-channel number (the same one the pane's rows carry, and unnumbered
when the signal is not acquired -- `enable_all` switches one off before the dump, so its
measurements read unnumbered there); on the fast mock
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read
`peak to peak=2` **exactly**, `average=0`, and the two flat-level types as their definition
says: **`top=0.957988`** and `amplitude=1.915983`. `top` is no longer 2/pi -- it is the *flat*
@@ -346,6 +369,42 @@ precedent, not an exception.
[probe-readings.md](probe-readings.md); the window rule itself is in
[invariants-measurements.md](invariants-measurements.md).
- `[gate-probe]` (stderr, `UDPSCOPE_PROBE_GATE_MS`) — the **per-measurement measurement gate**:
a gated measurement must read the shared band and an ungated one the whole window, and the
band must stay put while the data moves under it. It enables the sine, puts it on a plot
(the way `enable_all_probe` does — an unbound trace leaves the display epoch at 0, and a
still window is the state the `still=` leg asserts moves), adds **two** averages — index 0
ungated, index 1 gated, which is what makes the pane rebuild *derive* the band from the
measure set — and re-states the band's bounds to a fixed relative interval, 300 ms ago to
100 ms ago, where samples exist whatever the run's age. It first waits for the ring to be
older than the window, because the `plain=` assertion below is exact zero only while a
whole-seconds window of the mock's 1 Hz sine holds whole periods; a young ring clips the
window to a fraction of a cycle and the reading is merely small. Two lines:
```sh
python3 tools/mock_udpstreamer.py --port 8140 --rate 100000 --batch 100 --freq 1 &
sleep 1.5
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 UDPSCOPE_PROBE_GATE_MS=3000 \
./build/udpscope --server 127.0.0.1 --port 8140 2>&1 | grep -E 'gate-probe'
```
The first line must read **`band=1 placed=1 plain=1 split=1 gwin=1`**: one gated
measurement showed the band, the plot's region holds exactly the bounds the `RowSet` does,
the ungated average is zero (it read the whole window — `-1.36e-17 V` on the reference run),
the gated average differs (it read the band — `355.31 mV`, wherever in its period the
stream happened to be), and the pass booked `measure_gate_window` = the band moved onto the
stream's clock (`s0=`), which is the phase-free statement of *which* slice the gated walk
read. The second line, a second later, must read **`still=1 s0_moved=1`**: the band exactly
where it was put while the stream's clock moved under it — the *relative* behaviour the
gate exists for. The probe saves the bounds the way a drag end does; the exit dump's
**`gate:` line** is the round trip — a second run on the same config, with the probe unset,
must restore both measurements (`measurements: 2 average≈0 average≠0`) and read
`gate: on=1 bounds=[-0.300000..-0.100000]`. **Not in one run with the other measurement
probes** (`UDPSCOPE_PROBE_MEASURE_MS`, `_MEASURES_MS`, `_BANDAVG_MS`, `_LEVELS_MS`,
`_MEASUREPIN_MS`): they add measurements to one pane and these lines quote indexes 0 and 1.
The lines are in [probe-readings.md](probe-readings.md); the rule is in
[invariants-measurements.md](invariants-measurements.md).
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window