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

+24
View File
@@ -9,6 +9,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
tick as the channel dump; the status line only formats what is already there. The
badge's markup is likewise rewritten on a change of phase (twice a second), never on
all sixty frames.
- **The trigger circle is the badge's sibling, in its alphabet and under its rules.** It sits
before the badge on the status row (`main-window.ui` `trigger_status`) and says what the
badge cannot: the *trigger's* state -- solid green while an event is held or a capture is
filling, flashing green (the lit and dim halves) while armed and waiting, red while the
trigger mode is on but disarmed, and **hidden while the trigger mode is off**, because a
light for a mode the user is not in is noise and the mode text beside it shows no trigger
words then either. Same width-locked glyph in every state, same half-second square for the
flash, markup rewritten on a change of the state key only (`trigger_circle_state`), and the
exit dump reads the tick's cached answer (`dbg_trigger_circle`) because it cannot ask the
widget.
- **A `notify::<prop>` handler takes three parameters, a `value-changed` handler two.**
`GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while
`GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot
@@ -722,3 +732,17 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
box drawn from that is a box over the wrong thing. Its numbers depend on the *window's* size, which
under the documented `xvfb-run` is the window's 756x894 minimum (a 640x480 screen), so a run on a
larger screen gives different rectangles for the same panels.
- **GTK4 has no `GtkWidget::destroy` signal; the lifecycle hook is `close-request`, and
`gtk_window_destroy()` never emits it.** A `g_signal_connect(window, "destroy", ...)` on a GTK4
widget is a connect to a signal that does not exist: it never fires (and its "signal not found"
warning is in the GObject domain, not GTK's, so the dump's `gtk warnings:` counter cannot see
it). That is why the dialogs use `gtk_window_close()` when their *lifetime* matters (a close
clears `App::measure_dialog`, which a destroy would not), and why the main window's own handler
-- the scope windows' teardown, the debounced layout-save flush and the application quit -- is
connected to **`close-request`** and returns `FALSE` so the default destroy still runs.
Everything that ends the main window programmatically (the exit probe's `exit_probe_step3b`)
closes it with `gtk_window_close()` for the same reason. The **scope windows go with it**:
each is a `GtkWindow` added to the application, and one left behind keeps the GApplication's
main loop alive around a window that is gone -- the hang where the exit run never reached its
dump.
+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
+35 -2
View File
@@ -119,8 +119,11 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
a split one -- never a flat index, which is 0-based and says nothing about where the plot is.
What the cells share and what each keeps:
- **One time window.** Every cell of every row is a member of the one x link group, so a pan or zoom
in any cell moves them all, the crosshair is one x drawn in every cell (the hovered cell shows its
y), and the **vertical cursor pair is one shared time drawn in every cell** (`cursors_aligned`).
in any cell moves them all, the crosshair is one x drawn in every cell, and the **vertical cursor
pair is one shared time drawn in every cell** (`cursors_aligned`). The horizontal crosshair line is
the hovered cell's y **drawn in every cell of its figure row** -- the cells share that y, so the
line is one value spanning the row rather than stopping at the divider; no other row gets one
(`Figure::set_crosshair` spreads it, `[split-probe] cell-xhair` reads it).
A split is an arrangement of more plots, not a second time scale: nothing about the time axis changes
(see [invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)).
- **One y per figure row.** The cells of a figure row share one y range (the plotter's
@@ -340,3 +343,33 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
relayout, the panels' chips, the layout save) are done once by the caller rather than per slot. Re-enabling
does **not** put the traces back: the arrangement is the user's, and guessing where they were is worse than
leaving them off.
- **The full-screen toggle and a scope window are *views*: neither edits the arrangement nor takes a second
acquisition.** The per-row toggle (`RowSet::toggle_maximize_figure_row()`) saves every row's persistent
state (`RowState` -- the fields `carry_row()` copies, because a `Row` is not copy-assignable: its
`SlotSeries` owns a `unique_ptr`) plus the cells, the weights and the plot count, rebuilds the figure with
the asked-for row's plots moved to the front, and puts every field back on the second click; it saves
nothing, so a reopened session has the arrangement, not the view. Its overlay button is on screen
*always* (`App::maximize_buttons`, a set of its own -- `layout_buttons` is the editor's and is cleared with
it), placed from the same `FigureLayout` rects as the editor's buttons. A scope window
(`App::ScopeWindow`, `open_scope_window()`) owns a `RowSet`, a `PlotArea` and the area's placeholder
`Plot`, and is driven by the **main window's tick**: the same model mutex binds and acquires its rows
(same display mode, same epoch), and its span follows the main window's window or pin every frame. It is
therefore a second *view* -- the settle rule is that a scope must never acquire, plan or epoch anything of
its own. Closing the main window destroys its scopes first: a leftover toplevel keeps the GApplication's
loop alive around a window that is gone (the exit run's hang).
- **A figure row's full-screen is one click from two places, and never from one figure.** The
toggle sits on the row's top-right corner, **out of hiding only while the pointer is near that
corner**: its own placed rect inflated by `kMaximizeRevealPx`, per row, so approaching one
row's corner reveals that row's toggle and no other. The pointer is read from a
`GtkEventControllerMotion` on the **overlay**, not on the GL area -- a revealed toggle sits
under the pointer, and a leave on the GL widget would fire the moment the button appeared and
hide it again (the flicker the ancestor avoids); a leave on the overlay means the pointer left
the plot, which is when the toggles go away. The plot menu offers the same action as *Full
screen this figure* / *Restore the layout*;
both end in the same `toggle_fullscreen()` -- the `RowState` save/restore, the area's figure,
the toggles and the signal panels -- so the two cannot drift apart. A layout of a single
figure row carries **no** toggle (and no menu entry): there is nothing to enlarge *to*, and a
visible no-op is worse than none. The button reappears while a row *is* maximized, because
there it is the way back.
@@ -118,3 +118,15 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
marker says where the scope put the edge and a measurement mark says what a measurement measured;
neither is something a user may move. The dump prints `readout instruments locked: N`, which must
be 1 -- a drag is not something a probe can perform, so the state is what is asserted.
- **The crosshair's axis badges are painted *over* the tick labels, and that costs a flush.**
A badge is a fill in the crosshair's colour framing the value where the line meets the axis
it names — and it sits *in the label strip*, on top of the tick label at that row. The tick
labels are staged into `glyphs_over_`, the over-text batch the frame flushes **after every
line batch**, so a panel fill drawn at the ordinary moment lands *under* them: the label
prints through the badge and the backplate reads as transparent (the defect this rule was
learned from). `draw_crosshair_axis_badges()` therefore flushes the accumulated over-text
*first* — those labels are already placed, so it is one batch — then draws both fills, then
stages the badge texts, which leave with the rest of the over batch over everything. An
assertion on badge pixels inside a label's rows is the regression test: with the fill under
the text, the label's glyph pixels are still lit inside the badge's rect.
@@ -192,3 +192,15 @@ of.
Nothing is lost by switching: the dialog saves the current session *before* loading the picked
one, which is the only ordering in which a switch cannot lose what it switches from.
- **The event cache is keyed on the source *and its active configuration*, and the key moves
with the switch.** An event's trigger, its channel set and its units are the configuration's,
so the directory is `<base>/<host>-<port>/<configuration>/` (`EventStore`'s third constructor
argument) and `retarget_event_store()` -- called next to every `App::config_name` write and
resolving the name from `active_configuration()` when it is empty -- re-derives it, refreshing
both views of the list only when the directory changed. A store built without a configuration
keeps the legacy `<base>/<source>/` path, and the **first construction under a configuration
adopts the loose event files it finds there** (`*.npy`/`*.txt`/`*.bmp`, moved into the
configuration's folder): an upgrade keeps the events it had. The key is a *sanitized* path
component, the same rule the configuration files use, so a name typed with a slash cannot
leave the directory.
+155 -23
View File
@@ -305,8 +305,8 @@ Two lines of the self-check output are assertions in disguise:
on screen. Both ranges are on the **stream's** clock, which is the space `measure_window` and
the time marks are stored in -- a live axis is a fixed `[-window, 0]`, so comparing display x
would read no drift whatever the data did. `win` is the window as of the last **tick**
(`App::measure_view_window`) and is deliberately not read back here: the dump runs from the
destroy handler, by which point the exit probe has switched the trigger on, and
(`App::measure_view_window`) and is deliberately not read back here: the dump runs after
`g_application_run` returns, by which point the exit probe has switched the trigger on, and
`stream_at_x0()` then answers for a mode no frame was ever drawn in -- which read as a 45000 %
drift on a run whose every frame was exact. `pin` is the same tick's pin (`App::measure_pin_window`,
`off` when nothing was pinned) and it is the **target** while the view is pinned: a measurement
@@ -1348,6 +1348,125 @@ mock's five signals and dies with `OSError: [Errno 90] Message too long`, so the
two steps to it, which is visible in its `ges/x1`, `ges/x2` lines. A `gave up waiting after N
polls` line means no latch (or no pin) was ever reached, and is a reading about the stream.
## `[gate-probe]`
`UDPSCOPE_PROBE_GATE_MS`. The reading for **what the per-measurement gate does**: the gated
measurement reads the shared band, the ungated one the whole window, and the band is
*relative* — fixed on screen while the data flows under it, live or held. Measured output
(port 8140, `--rate 100000 --batch 100 --freq 1`):
```
[gate-probe] on: gate=[-0.300000..-0.100000] view=[-2.000000..0.000000] s0=2.022990 for=[0.022990..2.022990] band=1 placed=1 plain=1 split=1 gwin=1 ungated=-1.36e-17 V gated=355.31 mV
[gate-probe] still: gate=[-0.300000..-0.100000] s0=[2.022990->2.852990] still=1 s0_moved=1
```
- `gate=` is the band, in **display** x; `view=` the axis; `s0=` the stream time of the
display's x = 0 (`stream_at_x0()`); `for=` the pane's own window (the ungated one), in
stream seconds. The probe measures only once the ring is older than the window (`s0 >= 2`
here): the `plain=` assertion is exact zero because a whole-seconds window of the 1 Hz sine
holds whole periods, and a young ring would clip it to a fraction of a cycle.
- `band=1` — one gated measurement was enough to show the band, because the band is shared by
every gated measurement and shown exactly while one exists. `placed=1` — the row's plot
region holds exactly the bounds the `RowSet` does: the drawing and the state are one pair of
numbers, not two.
- `plain=1` — the ungated average is zero (`-1.36e-17 V`): it read the whole window.
`split=1` — the gated average differs (`355.31 mV`, wherever in its period the stream
happened to be): it read the band. The pair is the per-measurement split on one signal.
- `gwin=1` — the pass booked `App::measure_gate_window` as the band moved onto the stream's
clock, within 5 % of the band (the probe's `s0` read and the pass's can straddle one
production interval). That is the phase-free statement of *which* slice the gated walk
read; the slice's mean value itself depends on where in its period the stream is, which is
why the assertion is on the window and not on the value.
- The second line is the **relative** assertion, one second later: `still=1` — the band
exactly where it was put — and `s0_moved=1` — the stream's clock moved under it.
`still=0` is a gesture echo adopted outside a drag; `s0_moved=0` on a live stream means the
display epoch stopped following, which is the unbound-trace state the probe's assignment
exists to prevent.
- The exit dump carries the session leg: `gate: on=1 bounds=[-0.300000..-0.100000]`, and a
second run on the same config with the probe unset restores both measurements with their
gate flags (`measurements: 2 1.sine average=-0.000005 1.sine average=-0.338823` on one
restore run -- numbered with the signal's active-channel number, the same one the pane's
rows carry) and the same band.
## `[crosshair-probe]`
`UDPSCOPE_PROBE_CROSSHAIR_MS`. The reading for **the crosshair's axis badges**: a real pointer
hover — synthesised through the GTK adapter's own controller, a plain move with no button —
must place the crosshair, and with it the two badges the plot writes in the label strips: the
y value in the y strip, the x time in the x strip, each inside its strip and outside the axes.
Measured output (viewport pixels; the absolute numbers are the headless window's own):
```
[crosshair-probe] active=1 y=[74x47@31,588] x=[59x47@749,1529] y_ok=1 x_ok=1 y_centre=1
[crosshair-probe] off: plot_flag=0
```
- `active=1` is the hover having reached the plot (`Crosshair::active`) — the adapter's
viewport conversion is what this probe exercises; the pixels themselves are the plotter
suite's (`crosshair_ink_stays_inside_the_axes`, which asserts the badges' rects against the
frame and pins the off leg too).
- `y_ok=1 x_ok=1` are the two placements: the y badge right-aligned inside the y label strip
(its right edge on the axis line, where the y ticks end), the x badge inside the x label
strip below the axes. Zero-sized badges, or one outside its strip, is the defect.
- `y_centre=1` is the y badge **centred on its line**: its middle row is the pointer's screen
y (the probe injected one). `0` is the top-aligned badge — the line running along the
badge's top edge, reading as if the number belonged to the tick below it — which is half a
badge off, well beyond the one pixel of slack.
- The second line flips the flag the preference drives (`set_crosshair_axis_labels(false)`)
and reads the plot's own flag back: `plot_flag=0` — a preference that reaches the file but
not the plots is the failure this leg names.
- The pixel-level assertions live in the plotter's suite; this probe is the adapter's half.
The preference itself is `[prefs-probe]`'s `xhair=` field, which reads the settings file
back from disk after the dialog toggles it.
## `[maximize-probe]`
`UDPSCOPE_PROBE_MAXIMIZE_MS`. The one-click **full-screen row** toggle, exercised through the
`RowSet` call the button's handler makes (a probe cannot click an overlay button the frame
places), arranged as two single-cell rows with one signal each. The two reads after it are the
**corner reveal**: no pointer over the area, then a pointer position in the first row's corner
(set directly -- the probe owns the state the motion controller fills). Measured output:
```
[maximize-probe] single_buttons=0 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
[maximize-probe] corner: hidden_when_off=1 shown_when_near=1 other_row=0
```
- `single_buttons=` is the toggle count *before* the second row is added, while the layout is
the default one figure: **0** — one figure has nothing to enlarge *to*, so it carries no
button (and `buttons=` is what the same count reads once the second row exists).
- `before:` is the arrangement as set: two plots, two figure rows, the signals on their rows,
and `buttons=` one overlay toggle per figure row (the toggles are on screen whether or not
the layout editor is up).
- `max:` is the toggle: one plot, one figure row, `flag=1`, and the *maximized row's own*
signal (`row0=sine`) — the row that was asked for, not whatever sat first.
- `back:` is the toggle again: the arrangement, the plot count and both rows' signals exactly
as before, `flag=0`. A signal that changed here is the state save/restore (`RowState` in
`plot_host.*`) losing a field; the restore is a view change, never an edit.
- `corner:` is the proximity reveal: `hidden_when_off=1` (no pointer in the area, so nothing
shows), `shown_when_near=1` (a pointer in the first row's top-right corner brings *that*
row's toggle out), and `other_row=0` -- the second row's toggle stays hidden, because the
zone is per row. A `shown_when_near=0` names a dead reveal path (the motion hook or the zone);
`other_row=1` a zone that leaked across rows.
## `[scope-probe]`
`UDPSCOPE_PROBE_SCOPE_MS`. The **second window**: `open_scope_window()` twice — once copying
the main window's figure 0, once empty — and read back a few frames later (the main tick is the
scope's bind/acquire/repaint pump). Measured output:
```
[scope-probe] count=2 copied='sine' bound=1 empty_ok=1 windows=1
```
- `count=2` is both windows having been created and retained; `windows=1` each being a visible
top-level window.
- `copied='sine'` is the copy's first slot holding the main window's figure-0 signal, and
`bound=1` the tick having resolved it to a live channel — the scope is bound to the same
model, not a snapshot taken at creation.
- `empty_ok=1` is the `-1` call producing an empty row, which is the other entry point the
overflow menu uses.
## `[search-probe]`
`UDPSCOPE_PROBE_SEARCH_MS`. The handler calls `condition_probe()` directly — the condition is
@@ -1432,10 +1551,10 @@ popover row reads `failed: <strerror>` — one stderr line, no retry storm again
```
[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01 0 0 1
[defaults] calibrated: 2.5 -1.25 model=2.5 -1.25
[configs] before=default active=default
[configs] save-as=1 names=probe,default active=probe
[configs] before=default active=default store=default
[configs] save-as=1 names=probe,default active=probe store=probe
[configs] round trip read=1 comment='written by the configs probe' buffer_samples=10000000 relative=0 capture=0 plots=1 signals=4 measures=0 math=0 noise=-1 hf=-1
[configs] after forget names=default
[configs] after forget names=default active=default store=default
```
`noise=` and `hf=` are the first measurement's band and corner as they came back from the file. The probe
sets them to **7.5 and 250** before saving, so a key that never reached the file reads back as the defaults
@@ -1450,16 +1569,17 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file
| `[configs] names` | gains `probe`, and loses it after `forget` | a save that writes somewhere the listing does not read, or a delete that leaves the file |
| `[configs] active` | `probe` after the save-as | the `active` file not written, so the next start opens the wrong configuration |
| `[configs] round trip` | `read=1`, the note verbatim, `buffer_samples` non-zero | the note or the startup settings not persisted — a configuration that carries a layout but not the buffer size it was tuned with |
| `[configs] store` | the **event cache's configuration key**, following the active one: `default` → `probe` → `default` | a cache still keyed on the source alone (the key never moves), or a switch that renames the session but leaves the events list showing the configuration it left: `store=default` on the `save-as` line, or `store=probe` after `forget`, names it |
## `[rowmenu]`
```
[rowmenu] main 4 'Remove signal' h=18 label_x=26 accel_right=-1/174 ok=1
[rowmenu] main rows=5 bad=0 labels=[Y axis: auto scale,Y axis: fixed range...,Fit X axis,Fit all,Remove signal]
[rowmenu] remove 0 'Back' h=18 label_x=52 accel_right=-1/178 ok=1
[rowmenu] remove rows=6 bad=0 labels=[Back,sine,square,ramp,noise,All traces on this plot]
[rowmenu] summary main_rows=5 main_bad=0 remove_rows=6 remove_bad=0 after_back_rows=5 after_back_bad=0 second_rows=6 second_bad=0 remove_row_in_main=1 remove_row_gone=1 can_open_again=1
[rowmenu] remove-clicked 'sine' trace_freed=1
[rowmenu] main 6 'Remove signal' h=18 label_x=26 accel_right=-1/248 ok=1
[rowmenu] main rows=7 bad=0 labels=[Y axis: auto scale,Y axis: fixed range...,Fit X axis,Fit all,Open this plot in a new window,Edit the layout...,Remove signal]
[rowmenu] remove 0 'Back' h=18 label_x=52 accel_right=-1/118 ok=1
[rowmenu] remove rows=1 bad=0 labels=[Back]
[rowmenu] summary main_rows=7 main_bad=0 remove_rows=1 remove_bad=0 after_back_rows=7 after_back_bad=0 second_rows=1 second_bad=0 remove_row_in_main=1 remove_row_gone=1 can_open_again=1
[rowmenu] remove-clicked '' trace_freed=1
```
| field | value it must read | the defect a wrong reading names |
@@ -1467,6 +1587,7 @@ sets them to **7.5 and 250** before saving, so a key that never reached the file
| `h` | 18 for every row of both views | the plot menu reverting to Adwaita's 34 px buttons — the row style not applied (`popover.udpscope-menu`) |
| `label_x` | 26 for every row, 52 for **Back** | labels not sharing one column; the Back row's 52 is its leading icon, which every menu indents for |
| `accel_right`/`W` | equal when the row has a shortcut (`Fit all` → `F`), `-1` when it has none | the shortcut not flush right, or shown where the action has none |
| `main_rows` | 6 for a one-figure layout, **7** with `Full screen this figure` (a layout of two or more figures), plus 1 with `Restore the layout` while one is maximized | the full-screen entry missing where it would act (it is deliberately absent for a single figure: there is nothing to enlarge to), or the layout-editor entry missing: the plot menu is the third route into the editor beside `l` and the overflow menu |
| `remove_rows` | 1 per occupied trace slot, plus `All traces on this plot` when there is more than one, plus the **Back** row | a remove view built from the wrong row, or missing its way out |
| `remove_row_gone`, `remove_row_in_main` | 1 | a submenu that floats beside the menu instead of replacing it |
| `can_open_again` | 1 | the second click not reopening: the `GtkMenuButton`-owned submenu that collapsed and left its row dead |
@@ -1620,7 +1741,7 @@ run they report:
[events] after events page opened: available=1 dock_sensitive=1 page_sensitive=1 page=events
[events] open alone: page=events page_box=347
[events] preview redrawn: signals=3 stored=480x132 row_drawn=1
[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023 n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 page=1 plot_w_stable=1 plot_w=756/756
[events] dir=/home/u/.cache/udpscope/events/127.0.0.1-21023/default n=3 newest: stem=20261013-184140-0003 dur=2.000000 pre=1.600000 post=0.400000 edge=5.660100 signals=3 samples=20001 rate=10000 seq=3 thumb_bytes=190134 recorded=1 page=1 plot_w_stable=1 plot_w=756/756
[events] activated row0: replaying=1 page=events page_box=347 tick=0 window=2.000 axis=[-1.600,0.400] marker=0.000 thumbnail=1 thumb_size=138x46 thumb_visible=1 rows_kept=1 selected=2/2
[events] dock events: rows=3 pane_rows=3 same_titles=1 sensitive=1 dock_w=268 on_screen=1 marked=0 members=0 max_ok=1 expected_marked=1 at=after pane replay
[events] dock replay: clicked=1 replaying=1 waveform_changed=1
@@ -1857,9 +1978,9 @@ These ride on the `[cursors-probe]` readings (every state line gets a `dock curs
## `strip:` (the readouts strip)
```
[cursors-probe] <what> strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=4 measures=0 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=100 groups_ok=1
[measures-probe] <what> strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=0 measures=12 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=010 groups_ok=1
[measures-probe] start strip: visible=0 expected=0 visible_ok=1 dock_shown=1 cursors=0 measures=0 same_text=1 h=0 sens_same=1 collapsed=1 window=756 groups=000 groups_ok=1
[cursors-probe] <what> strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=4 measures=0 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=100 groups_ok=1 add=1
[measures-probe] <what> strip: visible=1 expected=1 visible_ok=1 dock_shown=1 cursors=0 measures=12 same_text=1 h=26 sens_same=1 collapsed=1 window=756 groups=010 groups_ok=1 add=1
[measures-probe] start strip: visible=0 expected=0 visible_ok=1 dock_shown=1 cursors=0 measures=0 same_text=1 h=0 sens_same=1 collapsed=1 window=756 groups=000 groups_ok=1 add=1
[cursors-probe] dock hidden strip: visible=1 expected=1 visible_ok=1 dock_shown=0 cursors=4 measures=0 same_text=1 h=26 sens_same=1 ...
[cursors-probe] dock restored strip: visible=1 expected=1 visible_ok=1 dock_shown=1 ... collapsed=1 window=756
```
@@ -1868,7 +1989,7 @@ One `strip:` line follows every `dock cursors:` / `dock measures:` line, and eac
- **`visible` equals `expected`, and `expected = (!dock_shown || collapsed) && (cursors on || measures non-empty)`** (`dock_shown` is `show-sidebar`, `collapsed` the split view's, both read in the same frame as `visible`; "cursors on" is the cursors toggle, not the `cursors=` chip count, which is a rebuild's and is 1 even with them off). `visible_ok=1` is that equality; `visible_ok=0` is `sync_readouts_strip()` not run, or run before the reconciliation. A single such line right after the probe's own `update_side_visibility()` or on the very first line (the tick has not set `collapsed` yet) is the sub-frame before the next tick and not a defect.
- **What each leg reads.** `[measures-probe] start` (before the probe adds anything, from a **fresh** config: a stored session brings its measurements back) is the "neither" state: `visible=0 expected=0 groups=000 h=0`. The measures probe's later lines have measurements and the cursors off: `groups=010`. The cursors probe alone has the cursors on and no measurements: `groups=100`; run after the measures probe in the same session it has both: `groups=111`. A line with the strip visible and `groups=000`, or hidden with a group on, is `sync_readouts_strip()` not called from that path.
- **`groups=<cursors><measures><separator>`** is which of `strip_cursor_group`, `strip_measure_group` and `strip_sep` are visible; **`groups_ok=1`** is that they equal (cursors on, a measurement, both). `0` is a group left over from the previous state.
- **`groups=<cursors><measures><separator>`** is which of `strip_cursor_group`, `strip_measure_group` and `strip_sep` are visible; **`groups_ok=1`** is that they equal (cursors on, a measurement, both). `0` is a group left over from the previous state. **`add=1`** is the strip's `[+]` sitting after the last measurement chip (a button, not the chip box) -- the strip adds a measurement itself rather than only opening the pane; it is structural because a probe that clicked it would open a dialog on every report.
- **`h=0` with `visible=1` on the `cursors-probe ... on` and `measures-probe ... added` lines** is the strip shown in the same frame the probe reads it, before its first allocation; the next line reads 26. It is not a `visible_ok` miss: the toggle handler and `rebuild_measures_pane()` call `sync_readouts_strip()` themselves, so `visible` is already right.
- **`visible=1 expected=1` on every line that has something to read, under `xvfb-run`**, the `dock restored` line and the lines with `dock_shown=1` included: the window is 756 px, below the 900 px `kSidebarRoom`, so the split view is collapsed, the dock is an overlay on the plot, and the strip stays. `visible=0` there is the old rule (hidden whenever the dock is shown) or `collapsed` not being read. The wide case (`collapsed=0`, `dock_shown=1`, `visible=0 expected=0`) is **not reachable from a probe**, because a window with no manager cannot be resized by `gtk_window_set_default_size()`.
- **`h` is 26 with `visible=1`, 0 with `visible=0`.** Any other visible height is the chips setting the strip's height (the button trim in `ui_widgets.cpp` or the `height-request` lost).
@@ -2118,15 +2239,22 @@ same reason.
## `[prefs-probe]`
```
[prefs-probe] opened=1 rows=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
[prefs-probe] opened=1 rows=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000/default cfg=1 cfg_switch=1
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark xhair=0 live=1
[prefs-probe] default restored: file=''
[prefs-probe] browse: cancelled=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events'
```
`UDPSCOPE_PROBE_PREFS_MS` opens the preferences, changes every row, and reads the settings file *back
from disk* after each change -- not from `App::settings`, because a dialog that edits its own copy and
never writes is exactly the failure this has to catch. **A change applies as it is made**: the rows are
never writes is exactly the failure this has to catch.
`store=` is the directory the cache is **really** using, and the `cfg=`/`cfg_switch=` pair is that
directory's key: `cfg=1` reads `store=` ends `/<source>/<active configuration>/` (the cache is per
source *and* configuration, so two configurations of one source never list each other's events), and
`cfg_switch=1` reads `retarget_event_store()` -- the one helper every configuration switch calls --
moving the key to another configuration and back. A `cfg=0` names a store still keyed on the source
alone; a `cfg_switch=0` a switch that leaves the cache pointing at the configuration it left. **A change applies as it is made**: the rows are
libadwaita's (`AdwEntryRow`, `AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow`), and the `preferences.ui`
dialog has no Apply button for the probe to press.
@@ -2138,8 +2266,10 @@ dialog has no Apply button for the probe to press.
plus the source's folder name**, which is the reading that says the row shows the path in force rather
than a plausible string.
- The second line must echo the probe's own input: directory `/tmp/udpscope-prefs-events`, keep `7`,
relative time `1`, theme `dark`. Any field that differs means the row, the write or the parse dropped
it. `live=1` is the theme having reached the running style manager rather than only the file.
relative time `1`, theme `dark`, and `xhair=0` — the crosshair's axis-badge switch, which the
probe drives off exactly as it drives the rest, and which reaches the running plots at once
(`RowSet::set_crosshair_axis_labels()`). Any field that differs means the row, the write or the
parse dropped it. `live=1` is the theme having reached the running style manager rather than only the file.
- `default restored: file=''` closes the loop from the other side: the probe puts the default path back
into the row, and the file must end up with **no** `event_cache_dir` at all. A path in the file is a
user's choice, and leaving the default is not one -- pinning it there would stop the setting following
@@ -2152,7 +2282,7 @@ dialog has no Apply button for the probe to press.
```
[prefs-probe] opened=1 apply=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000
[prefs-probe] opened=1 apply=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000/default cfg=1 cfg_switch=1
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
[prefs-probe] default restored: file=''
[prefs-probe] browse: toplevels=3 extra=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events'
@@ -2862,9 +2992,10 @@ inside the panel does not close it), `click_outside_closes=1` (a click on the pl
[split-probe] ruler-labels split=[row 1,row 2] mixed=[row 0,row 2]
[split-probe] shared-y y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1 ranges=[A=-3.000000..7.000000 B=-3.000000..7.000000 | fit A=-1.04..1.04 B=-1.04..1.04 data=-1..1] axes_w=[671.599976,671.599976] auto=1
[split-probe] x-axes row0_labelled=[1,0,1] bottom_labelled=[1,1,1] gap_ok=[1,1,1] gap_px=[3.0/3.0,3.0/3.0,3.0/3.0] col_gap_px=3.0 caption_ok=[1,1,1] last_plot_zoom=1
[split-probe] cell-xhair left=1 right=1 centre_dy=0.0 sibling=0 (the horizontal line spans a split row's shared y, and no other row)
```
`UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). 28 steps (0..27), 250 ms apart;
`UDPSCOPE_PROBE_SPLIT_MS` (stderr; see [verification.md](verification.md)). 31 steps (0..30), 250 ms apart;
each restructures and the *next* step reports the frame that was laid out with it, so a line's label
names the arrangement the line **reads**, not the call that made it. The state lines share one format:
`plots` (`row_count()`), `frows` (`figure_row_count()`), `cells=[..]` (per figure row), `widths=[..]`
@@ -2887,6 +3018,7 @@ The other lines, in order:
| `row+cell` | `cells=[2,2]`, `weights=[3,1\|-]` | row 0's cell weights lost or moved when a row and a cell were inserted below it (they belong to the figure row) |
| `caps-calls` | `insert_cell=[1,1,0] refused_cell=1 insert_row=[1,1,0] refused_row=1 plots=8` | a cap that is not reached or not held: row 0 starts with two cells and takes two more, refusing the third call at 4; two rows then fit before the plot total reaches 8 and the third is refused; `plots` must stop at **8** |
| `caps` | `plots=8 frows=4 cells=[4,2,1,1]`, `tile=1` | a ninth plot, or a fifth cell in a row |
| `cell-xhair` | `left=1 right=1 centre_dy=0.0 sibling=0` | the crosshair's horizontal line, placed through `Figure::set_crosshair()` on the **second** cell of a two-cell row: it must also be drawn in the first cell (`left=1`) at the same screen row (`centre_dy`) — the cells share one y, so the line is one value — and must not appear in the figure's other (unsplit) row (`sibling=0`). A `left=0` names a row whose cells each keep their own line; a `sibling=1` a y spread across rows that do not share one |
| `remove` | `plots=7 cells=[3,2,1,1]` | `remove_cell()` on a multi-cell row that removed the row, or that dropped a neighbour's traces |
| `roundtrip-io` | `wrote=1 read=1 plots_in_file=3` | a save that was refused, or a file that lost a `[[plot]]` table |
| `roundtrip` (state line) | `cells=[1,2] widths=[756\|524,229] weights=[-\|3,1]` and the three traces on `1`, `2a`, `2b` | the arrangement did not survive `collect_layout()` -> file -> `apply_layout()` |
+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