trigger: a phosphor persistence, driven by the plotter's density image

The trigger pane gains the display the user asked for and the library now provides, and the
persistence it already had is renamed to what it is.

**Persistence is one image; the last-N-traces display is "multiple events".** Both live in a new
`Advanced` expander in the trigger panel, together with the settings that are set up once and left
alone. `persistence` (a switch) turns on the plotter's `Plot::set_density()` for every row:
every triggered acquisition is accumulated into a screen-sized intensity grid drawn behind the
traces, brightness being how many acquisitions covered a cell -- no depth to choose, no memory
that grows with the acquisitions (2.2 MB over four 573x412 rows in the probe's run). `fades by`
picks the fade the user asked for: **acquisitions** (each keeps a factor of what was before it,
0.5 by default -- the phosphor afterglow) or **time** (a time constant in stream seconds, so the
display fades while you watch whatever the trigger rate).

- **Trigger mode only, as asked.** `apply_density()` (the tick) states the display on for `Held`
  and `Capturing` and off for `Live`, `Waiting`, `Paused` and a file, and turning it off drops the
  grid at once rather than at an acquisition a quiet trigger never sends. It is unpickable and
  reserves no layout space, exactly as the invalid-span bands are: a density can never be in the
  way of a cursor or a drop, and it cannot move the framing.
- **One deposit per acquisition, one frame after the frame that frames it.** `Plot::accumulate_density()`
  deposits what the plot holds through the transforms of its *last* layout -- the frame's own is
  computed inside the paint that follows the tick -- so `density_pending` is set beside
  `record_pending` and consumed by the next tick. In the same tick it was right on a Held-to-Held
  transition and wrong on the first latch, whose axis was still the live ring's. Switching the
  display on under an acquisition already on screen deposits it too, so the switch does not appear
  to do nothing until the next latch (`single` mode may never send one).
- **The session carries it**: `trigger_density`, `trigger_density_decay` ("acquisitions"/"time",
  the words the panel shows), `trigger_density_tau` and `trigger_density_factor`, written whether
  or not it is on -- and `trigger_persistence` still means the multiple-event depth, so an older
  file is unchanged.
- **The library changed twice under this**: the deposit now counts a cell **once per trace per
  sweep** (counting the hits put one dense sweep at the ceiling of the brightness scale and hid the
  sweeps behind it -- the probe's `on` leg read `peak=18` for a single sweep and reads `peak=1`
  now), and a frame's upload is every row's rather than the last row's. Both are in the plotter's
  own commit.

`UDPSCOPE_PROBE_DENSITY_MS` reads it all back off the plots -- the plotter's own `ink()`/`peak()` --
with the panel's own widgets driving the switch and the fade, in four legs: **off** (`rows=0
ink=0.0 peak=0.0000`), **on** (`rows=4`, `peak=1.0000`), **step-acquisitions** (`peak=1.0000 ->
1.5000`, the factor then the new sweep) and **step-time** (`ratio=0.367879`, `1/e` for `dt = tau`).
It also reads the frame cost as an A/B **inside one run** (the accumulators at the switch against
the frames after it): `tick 0.74 -> 9.99 ms`, `draw 2.09 -> 20.91 ms` at four rows and 2.2 M cells
on llvmpipe.

Acceptance: clean build 0 errors and exactly the three documented warnings (12352-byte GResource
blob, two `MathNode::source`), `test-ui` 13/13, `test-help` green, a plain run with **0
Gtk-CRITICAL / Gtk-WARNING** and `gtk warnings: 0`, and the panel's own frame reading
`boxes=16 controls_inside=15/15 advanced_widgets=5/5 advanced_inside=5/5` (the block's controls
counted from the expander's child, which is not in the widget-parent chain -- and the search
block's walk now names *its* expander instead of "the first one", which the second expander would
have stolen).

Docs: the trigger/pinning invariants carry the rule and its five consequences, `probe-readings.md`
the four legs and the dump line, `verification.md` the probe's sequence and assertions, and the
manual's trigger page explains both displays to a user.
This commit is contained in:
Martino Ferrari committed 2026-10-16 17:28:54 +02:00
1 parent e14e1ee069
commit 974d6aa2d9
15 files changed
+993 -41

No files matched your search

@@ -376,6 +376,44 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
trigger that has stopped firing never reaches another publish. A depth change is therefore also
how the memory comes back.
- **The phosphor density is one *image*, and that is a different display from the layers above, not a
mode of them.** The trigger panel's `Advanced` block offers both, and the words matter: *multiple
events* is `persist_depth` (N retained acquisitions, N traces, dimmer with age) while *persistence*
is `TriggerConfig::density` -- the plotter's `Plot::set_density()`, a screen-sized intensity grid
every acquisition is deposited into and which fades either by a time constant or by a
per-acquisition factor. There is **no depth** on the density and no per-acquisition memory: the
image is `columns * rows * 4` bytes, one cell per device pixel of the row's axes (2.2 MB over the
four rows of the probe's run), so a thousand acquisitions cost what one costs. A cell's value is a
number of *sweeps*: the library deposits a cell once per trace per sweep however many of that
trace's own samples crossed it (`[density-probe] on` reads `peak=1.0000` for one sweep; a per-hit
deposit read 18) -- dwell is `Style::intensity_grading`'s question, not this one's.
Four things hold it together, each of which is a rule:
* **It is a trigger-mode display.** `apply_density()` (the tick) states `Held` and `Capturing` as
the states it is on in; `Live`, `Waiting`, `Paused` and a file are not, and turning it off drops
the grid **at once** rather than at an acquisition a quiet trigger never sends (the rule
`set_persist_depth()` follows). So it goes away with the trigger, and a re-enable starts from an
empty image rather than a stale one at another scale.
* **One deposit per acquisition, one frame *after* the frame that frames it.** `Plot::accumulate_density()`
deposits what the plot currently holds through the transforms of its **last** layout, because the
frame's own layout is computed inside the paint that follows the tick: `density_pending` is set
where a snapshot is *framed* -- beside `record_pending` -- and consumed by the next tick, so the
deposit is of the new acquisition drawn at the axis that acquisition is pinned to. Doing it in the
same tick was right on the common Held-to-Held transition and wrong on the first latch, whose axis
was still the live ring's.
* **The switch deposits what is already on screen.** `on && !density_was_on` sets the same pending
flag, so a user who turns persistence on while an event is held sees it instead of waiting for a
latch that `single` mode may never send.
* **It is the plot's, so a figure rebuild resets it** -- the grid lives on each `Plot`, and
`rebuild_all()` builds new ones. That is the one state a structural change (a row added, a signal
switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else,
and the alternative -- keeping the grid in the `RowSet` -- is what would make it not a plot
feature. The layout is unaffected either way: the density reserves no space and nothing on it can
be picked, exactly as `set_invalid_spans()` does.
* **The session carries it.** `trigger_density`, `trigger_density_decay`, `trigger_density_tau` and
`trigger_density_factor` in the layout file, written whether or not it is on -- a display set up
once and switched off is still the display you come back to. The decay is the *word* the panel's
dropdown shows ("acquisitions"/"time"), for the reason the condition's shapes are text.
## A latch is also a file
The pane that lists the cache is **on demand**: the toolbar's event-history button and the pane are