diff --git a/README.md b/README.md
index f466146..fea3c6e 100644
--- a/README.md
+++ b/README.md
@@ -1257,7 +1257,7 @@ snapshot's own stamps. That re-basing machinery already existed (it was written
frozen view and then never needed, since snapshots carry edge-relative stamps); this is
what finally uses it.
-**Persistence: the last N acquisitions, dimmer with age.** Trigger mode can keep the last
+**Multiple events: the last N acquisitions, dimmer with age.** Trigger mode can keep the last
`N` acquisitions on screen at once, each older one drawn at half the brightness of the one before
it (1, 0.5, 0.25, 0.125, …), which is what a phosphor display does with successive sweeps: they
are aligned on their own trigger edge, so the shape of the event repeats and whatever varies
@@ -1269,6 +1269,24 @@ pinned: the axis, the framing and every measurement still describe the event on
`N = 1` retains nothing at all — no copy, no layer — and lowering the depth frees the retained
snapshots there and then, because a trigger that has stopped firing never reaches another publish.
+**Persistence: one image, every acquisition.** The trigger panel's `Advanced` block carries the
+*other* display of the same acquisitions, and it is not a count at all: `persistence` turns on a
+**phosphor density** — the plotter's screen-sized intensity image (`Plot::set_density()`, one `f32`
+cell per device pixel of the axes rect) into which every triggered acquisition is deposited and
+which is drawn behind the traces. A cell's brightness is how many acquisitions passed through it,
+so the sweep on screen is the brightest, the older ones add on top **with no depth to choose and no
+memory that grows with them** — the image is 2.2 MB over four 573x412 rows in the probe's run, and
+a thousand acquisitions cost the same. `fades by` picks how it decays: `acquisitions` (each
+acquisition keeps the factor of what was before it, 0.5 by default — the phosphor afterglow
+schedule) or `time` (a time constant in stream seconds, so the display fades while you watch
+whatever the trigger rate). It applies in **trigger mode only** — while a captured acquisition is
+on screen — and it goes away with the trigger, on a pause and on an opened file, because the image
+is an image *of* a stream of acquisitions. Like the invalid-span bands it reserves no layout space
+and nothing on it can be picked. The `[density-probe]` run that measured it is in
+`docs/claude/probe-readings.md`; what it costs is in the same place (`[density-probe] frame cost:`:
+tick 0.74 → 9.99 ms and draw 2.09 → 20.91 ms over the same run, at four rows and 2.2 M cells a
+frame, on llvmpipe).
+
**And it says what it costs.** A retained acquisition is a whole window copied out of the ring, so
the retention is bounded by two things: the depth, and **`--buffer-mb`** — persistence may hold at
most as much again as the buffer does, which is the bound a long window on a fast stream reaches
diff --git a/deps/plotter b/deps/plotter
index 3ad02e5..6a6915e 160000
--- a/deps/plotter
+++ b/deps/plotter
@@ -1 +1 @@
-Subproject commit 3ad02e5120b5e2b8fb0a6d1a3ff4661b1d07325c
+Subproject commit 6a6915e64e9a9bd5e7b8c93cfa600334bf192b10
diff --git a/docs/claude/invariants-trigger-and-pinning.md b/docs/claude/invariants-trigger-and-pinning.md
index 885236c..4723572 100644
--- a/docs/claude/invariants-trigger-and-pinning.md
+++ b/docs/claude/invariants-trigger-and-pinning.md
@@ -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
diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md
index 1992e1a..1733be8 100644
--- a/docs/claude/probe-readings.md
+++ b/docs/claude/probe-readings.md
@@ -1899,27 +1899,31 @@ through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form
## `trigger panel:`
```
-[udpscope] trigger panel: boxes=17 controls_inside=16/16 search_widgets=4/4 search_inside=4/4
+[udpscope] trigger panel: boxes=16 controls_inside=15/15 search_widgets=4/4 search_inside=4/4 advanced_widgets=5/5 advanced_inside=5/5
```
The trigger panel's frame, read from the window: **the `.ui`'s own panel** (`trigger_body`), not the box
this app wraps it in — the heading row and the locked summary are the code's arrangement, and reading the
*wrapper* made this line report `boxes=2 … search_inside=0/4` the moment the heading moved out of the file.
That is the failure this line is for, so it is worth knowing it can happen to itself. `boxes` is the row
-boxes the `.ui` holds, `controls_inside`
+boxes the `.ui` holds and `controls_inside`
counts the sixteen controls the code built and then appended into those rows -- the last three being the
noise band and the HF reject, whose rows arrived with the noise-rejection work, and the persistence depth
-whose row arrived with the persistence display (the Arm/Force pair is a row of its own too, so `boxes` counts
-it while `controls_inside` does not: the pair is a *button* row, not a value) -- `search_widgets` is the four
-search buttons having resolved by id, and `search_inside` is those four being inside the block the expander
-opens onto. A control built and never placed -- an id that drifted between the file and the code -- reads as
-`controls_inside` below 16 while every other line still looks right, which is the panel that has a field
-missing.
+counts the fifteen controls the code built and then appended into the panel's own rows -- the noise band and
+the HF reject, the persistence-depth spin and the record switch among them, and the Arm/Force *pair* not,
+because that row holds buttons and a `boxes` count reads the rows either way.
-The search walk starts at `gtk_expander_get_child()`, not at the panel: an expander's child is not in the
-widget-parent chain at all, so walking up from a search button reaches no ancestor of the panel (see
-`invariants-gtk-and-chrome.md`). `[defaults] trigger:` prints the same panel's ten spins in tree order, so
-the two lines together read what the file gave and what the code put in it.
+`boxes` is **16** since the trigger's display settings moved into the `Advanced` expander: the multiple-event
+depth and the four density controls left the panel's own child list, and the expander that holds them is not a
+box. Those five are what `advanced_widgets`/`advanced_inside` count, for the same two reasons the search pair
+exists -- the ids resolving, and the controls being *in* the block -- and an expander's child is **not** in the
+widget-parent chain, so a control there is invisible to `controls_inside` by construction (see
+`invariants-gtk-and-chrome.md`). The walk for a block therefore starts at **its own** expander,
+`gtk_expander_get_child(GTK_EXPANDER(app.search_exp))`: the first version of this line took "the first
+expander in the panel", which stopped naming the search block the moment a second expander was added *before*
+it -- and would have read `search_inside=0/4` on a panel that was entirely correct. `[defaults] trigger:`
+prints the same panel's spins in tree order, so the two lines together read what the file gave and what the
+code put in it.
## `[persist-probe]` and `persistence:`
@@ -1972,6 +1976,58 @@ which are bound event series on the same rows — moved neither the axis nor the
waits for one), because a latch landing mid-frame leaves the mode `Held` while the rows still hold the live
ring's bindings, and a reading taken there says nothing about either.
+## `[density-probe]` and `density:`
+
+```
+[density-probe] arming on 'square', window 0.5 s, display off
+[density-probe] off enabled=0 fade=acquisitions tau=1.000 factor=0.500 rows=0 cells=0 ink=0.0 peak=0.0000 ratio=1.000000
+[density-probe] on enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 cells=2226150 ink=226596.0 peak=1.0000 ratio=1.000000
+[density-probe] step-acquisitions enabled=1 fade=acquisitions tau=1.000 factor=0.500 rows=4 cells=2226150 ink=262722.0 peak=1.5000 ratio=1.159429
+[density-probe] time-before enabled=1 fade=time tau=1.000 factor=0.500 rows=4 cells=2226150 ink=213362.2 peak=1.2182 ratio=1.000000
+[density-probe] step-time enabled=1 fade=time tau=1.000 factor=0.500 rows=4 cells=2226150 ink=78491.6 peak=0.4481 ratio=0.367879
+[density-probe] frame cost: tick before=0.74 ms (163 frames) after=9.99 ms (12 frames) draw before=2.09 ms (157) after=20.91 ms (7)
+[udpscope] density: frames=504 rows=4 cells=2226150 grid_bytes=8904600 ink=632845.4 peak=2.8 upload_bytes=1081350
+```
+
+The **phosphor density** (`Plot::set_density()`, the trigger pane's `persistence` switch): one
+screen-sized intensity image per row, every acquisition accumulated into it, drawn behind the traces and
+fading by a count or by the clock. The numbers are the plotter's own -- `DensityGrid::ink()`/`peak()` summed
+and maxed over the rows the display is on, which is the same source the library's tests read -- and the
+probe drives the panel's **own widgets** for the switch and the fade, so what it measures is this app's
+wiring.
+
+- **`off` is the display without it**: `rows=0`, so there is no grid at all and both numbers are zero.
+ That is also the run the frame cost is read against, and it is a plain run -- the density is off by
+ default and nothing draws it.
+- **`on` is one sweep**: `peak=1.0000` exactly, and `ink=226596` is the cells that sweep covered over
+ four rows. A cell takes **1.0 once per trace per sweep** however many of the trace's own samples crossed
+ it, which is what makes a sweep's peak *one* rather than the eighteen a per-hit deposit read on the same
+ run -- and what makes the sweeps behind it visible at all.
+- **`step-acquisitions`** deposits one more sweep: `peak=1.0000 -> 1.5000`, which is `1 + 0.5 x 1` -- the
+ panel's factor 0.5 applied to what was there, then the new sweep's own pass on top. `ink` rises from
+ `226596` to `262722` for the same reason: the old image halved and one sweep landed on it. The ratio is
+ **not** a constant to expect -- the acquisition on screen can move between the two reads, so what it says
+ is "the old image halved and a pass landed on it"; the exact relation is the *library's* case
+ (`a_density_halves_per_acquisition_and_keeps_the_newest_brightest`), where the same frame is deposited
+ twice.
+- **`step-time` is `1/e` by construction**: one step of `dt = tau` through the plot's own `decay_density()`,
+ so `ratio=0.367879` is `exp(-1)`. The leg before it (`time-before`) reads the same image under the time
+ fade, and the pair is the reading that says the *panel's* constant reaches the grid.
+- **`frame cost:` is the A/B inside one run**: the accumulators as of the tick the display was switched on
+ against the frames after it, so both means are the same stream, the same layout and the same mock. At four
+ rows of 573x412 cells it reads **tick 0.74 -> 9.99 ms** and **draw 2.09 -> 20.91 ms**: the per-frame
+ conversion, upload and deposit. It is llvmpipe, and the arithmetic is the grid's -- 2.2 M cells, one byte
+ each, every frame the time fade moves it.
+- **The dump's `density:` line** is captured on the last tick the display was **on**, not read at exit: the
+ exit probe switches the trigger off before it dumps, which takes the display with it, so a dump that asked
+ the plots would say `rows=0` about a run whose tail had the image up. `upload_bytes` is the **most** any
+ one frame uploaded -- one byte a cell, over the rows that moved -- and not the last frame's, for the same
+ reason.
+
+**Read `peak` as the display's ceiling**: with the default `full_scale` of 1.0 a cell at 1.0 is full ink, so
+a steady display under the acquisition fade sits between 1 and 2, and a `peak` well above that (the
+per-hit deposit read 189 after two sweeps) is the defect this probe's `on` leg now catches.
+
**And `N = 1` is the display without the feature**, which is the same command at
`UDPSCOPE_PROBE_PERSIST_DEPTH=1`:
diff --git a/docs/claude/verification.md b/docs/claude/verification.md
index 5c10705..b7d4ec4 100644
--- a/docs/claude/verification.md
+++ b/docs/claude/verification.md
@@ -114,6 +114,34 @@ precedent, not an exception.
the axis, the framing and the measurements still describe the current event. `alphas=` is the decay
schedule in use, oldest first — halves, and `1/2^age` at that. Both lines and the two exit-dump
lines are in [probe-readings.md](probe-readings.md).
+- `UDPSCOPE_PROBE_DENSITY_MS` (+ `UDPSCOPE_PROBE_DENSITY_TAU`, default 1.0 s) — the **phosphor
+ density**: one screen-sized intensity image every triggered acquisition is accumulated into, drawn
+ behind the traces, fading by a per-acquisition factor or by a time constant. It states the display
+ **off** through the panel's own switch first, so the `off` leg is the run without it, then arms the
+ trigger on the mock's `square` with one signal per row — the arrangement the persistence probe uses
+ — and only then switches it on, so the acquisition already on screen is the first image. Four legs,
+ each a separate `g_timeout`, so whole frames happen between them:
+
+ ```sh
+ python3 tools/mock_udpstreamer.py --port 9551 --rate 500000 --batch 1000 &
+ PY=$!; sleep 0.5
+ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=14000 \
+ UDPSCOPE_PROBE_DENSITY_MS=2500 ./build/udpscope --server 127.0.0.1 --port 9551 2>&1 | \
+ grep -E 'density-probe|density:'
+ kill $PY
+ ```
+
+ **The assertions, in order:** `off` reads `rows=0 cells=0 ink=0.0 peak=0.0000` (there is no grid at
+ all); `on` reads `rows=4` — every row has its own image — and **`peak=1.0000`**, one sweep's ink
+ being one per cell it covers; `step-acquisitions` reads **`peak=1.5000`**, which is the panel's
+ 0.5 applied to the old image plus the new sweep's own pass; and `step-time` reads
+ **`ratio=0.367879`**, one step of `dt = tau` through the plot's own `decay_density()`. A `peak` in
+ the tens for the `on` leg is the *per-hit* deposit -- one sweep counted once per sample that
+ crossed a cell -- which saturates a single acquisition and hides the sweeps behind it. The line
+ also carries the app's own A/B of the frame cost (`tick`/`draw` before and after the switch, in
+ one run), and the exit dump's `density:` line says what the last frame it was on held. Both lines
+ and the dump line are in [probe-readings.md](probe-readings.md).
+
- `UDPSCOPE_PROBE_TRIGRENUM_MS` — the trigger's capture must survive a **renumbering of its own
channel**. It sets the reported geometry through the real widgets (1 s window, 10 % pre share,
hold-off from `--holdoff`) on the mock's *square* alone, polls for a capture to open, and then
diff --git a/help/C/org.udpscope.app/trigger.page b/help/C/org.udpscope.app/trigger.page
index c50903d..5f85d2b 100644
--- a/help/C/org.udpscope.app/trigger.page
+++ b/help/C/org.udpscope.app/trigger.page
@@ -93,9 +93,9 @@
offers a pulse width to a window trigger.
-
- Persistence: keeping the last few acquisitions
-
Persistence decides how many of the most recent triggered acquisitions stay on
+
+ Multiple events: keeping the last few acquisitions
+
multiple events decides how many of the most recent triggered acquisitions stay on
screen at once. It is 1 by default, which is the event alone — the display you get
when you ask for no persistence — and it can be raised to 8. Each older acquisition is drawn
at half the brightness of the one before it (1, 0.5, 0.25, 0.125, …), and they are all aligned
@@ -107,7 +107,7 @@
The older acquisitions are drawn behind the current one and nothing else changes
because of them: the axis still frames the event on screen, the trigger marker still marks
its edge, and the measurements still describe the current frame. Lowering
- Persistence back to 1 remembers nothing and frees the retained acquisitions
+ multiple events back to 1 remembers nothing and frees the retained acquisitions
immediately.
Two things bound how many are drawn, and the panel tells you when one of them bites.
@@ -120,6 +120,40 @@
so persistence may hold at most as much again as the buffer (--buffer-mb) does.
On a long window at a high sample rate that bound is reached before the eighth layer.
+
+ Persistence: how often the trace has been here
+
persistence — the switch in the trigger panel's Advanced block — keeps
+ one image of the last acquisitions instead of their traces. Every acquisition is
+ accumulated into it, so a cell's brightness means how many of them passed through it: the
+ sweep on screen is the brightest, the ones behind it add on top, and where several agree the
+ ink is brighter still. It is off by default.
+
+
Two things make it different from multiple events beside it, and the panel offers
+ both because they answer different questions. It never runs out of room: the image is
+ the size of the plot on screen, so a display of a thousand acquisitions costs what a display of
+ one costs, and there is no count to choose. And it fades, in one of two ways —
+ fades by picks them:
+
+
acquisitions: every acquisition keeps
+ per acquisition of what was there before it (0.5, the default, is the schedule a
+ phosphor's afterglow has: 1, 0.5, 0.25, …). The display then shows the last few sweeps
+ whatever the trigger rate.
+
time: the image fades by time constant s as the display runs —
+ one constant is the time for it to fall to a third of its ink — so a slow trigger leaves
+ faint sweeps and a fast one leaves bright ones, and the picture is the same whichever the
+ stream is doing.
+
+
+
Persistence is a trigger mode display, like the trigger itself: it is drawn while a
+ captured acquisition is on screen and goes away with the trigger, on a pause, and when you open
+ a stored waveform. Nothing about it moves the framing — the axis is still the current event's,
+ the older sweeps only add ink — and it reserves no space in the plot and cannot be clicked, so
+ it is never in the way of a cursor or a drop.
+
+
Both displays live in the trigger panel's Advanced block, together with the
+ trigger's other settings that are set up once and left alone.
+
+
If a depth cannot be shown in full, a notice says how many layers fit, and the panel's
tooltip carries the same rule. Persistence is a display of what has already been captured: it
diff --git a/src/layout_store.cpp b/src/layout_store.cpp
index ef8d9be..857cfb9 100644
--- a/src/layout_store.cpp
+++ b/src/layout_store.cpp
@@ -520,6 +520,14 @@ bool load_layout(const std::string& path, LayoutState& out) {
double n = 0.0;
num(n);
t.persistence = static_cast(n);
+ } else if (k == "density") {
+ t.density = v == "true";
+ } else if (k == "density_decay") {
+ t.density_decay = v == "time" ? "time" : "acquisitions";
+ } else if (k == "density_tau") {
+ num(t.density_tau_s);
+ } else if (k == "density_factor") {
+ num(t.density_factor);
}
};
if (section == Section::Top) {
@@ -723,6 +731,13 @@ bool save_layout(const std::string& path, const LayoutState& state) {
o << "trigger_band = " << state.trigger.noise_band << "\n";
o << "trigger_hf = " << state.trigger.hf_fc << "\n";
o << "trigger_persistence = " << state.trigger.persistence << "\n";
+ // The phosphor density, the display that is an image rather than a set of traces. Its four fields
+ // are written whether or not it is on, like every other trigger setting: a display set up once and
+ // switched off is still the display you come back to.
+ o << "trigger_density = " << (state.trigger.density ? "true" : "false") << "\n";
+ o << "trigger_density_decay = " << toml_escape(state.trigger.density_decay) << "\n";
+ o << "trigger_density_tau = " << state.trigger.density_tau_s << "\n";
+ o << "trigger_density_factor = " << state.trigger.density_factor << "\n";
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n";
if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n";
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
diff --git a/src/layout_store.hpp b/src/layout_store.hpp
index 37809c8..6df6015 100644
--- a/src/layout_store.hpp
+++ b/src/layout_store.hpp
@@ -100,7 +100,23 @@ struct LayoutState {
/// How many of the most recent acquisitions the trigger mode keeps on screen (1 = the event
/// alone). Part of the trigger's own setup, like the window it captures and the shape it waits
/// for: it is what the display does with the acquisitions, and there is no second switch.
+ ///
+ /// This is the **multiple event** display -- N retained acquisitions, drawn as N traces. The
+ /// phosphor density beside it is a different display with a different cost (one screen-sized
+ /// image against N window copies), which is why it has its own four fields below and why this
+ /// one keeps the key it has always had: an older file's `trigger_persistence` is still the
+ /// multiple-event depth.
int persistence = 1;
+ /// The **phosphor density** (`TriggerConfig::density` and the three beside it): whether the
+ /// trigger-mode display accumulates every acquisition into one image, what it fades by, and the
+ /// constant or factor that fade uses. Persisted because it is a display the user set up and
+ /// comes back to -- the trigger settings around it already are.
+ bool density = false;
+ /// "acquisitions" | "time": the same two words the panel's dropdown shows, for the reason the
+ /// shapes above are stored as text -- an index would tie the file to a declaration order.
+ std::string density_decay = "acquisitions";
+ double density_tau_s = 1.0;
+ double density_factor = 0.5;
/// Whether the file said anything about the level. A session that did keeps it; one that did not
/// leaves the panel's own "follow the channel's range" default alone.
bool level_given = false;
diff --git a/src/trigger.hpp b/src/trigger.hpp
index 04fe2f0..c7a09a4 100644
--- a/src/trigger.hpp
+++ b/src/trigger.hpp
@@ -13,6 +13,19 @@ namespace udpscope {
/// How an armed trigger re-arms after it fired.
enum class TriggerMode { Normal, Single };
+/// How a trigger-mode **phosphor density** fades (the plotter's `plot::DensityDecay`, restated here
+/// so the panel's dropdown, the session file and the model's copy of the setting all name one
+/// thing). The two answer different questions and the user asked for both: see the longer note on
+/// `TriggerConfig::density_decay`.
+enum class PersistDecay {
+ /// Every acquisition multiplies the image by `density_factor` before depositing its own sweep,
+ /// so the N-th acquisition back is the factor to the N. Scales with the trigger rate.
+ Acquisitions,
+ /// Every frame fades the image by `exp(-dt / density_tau_s)` of stream time, so the display
+ /// fades while you watch whatever the trigger rate.
+ Time,
+};
+
/// The trigger panel's configuration (spec item 2.2).
///
/// The trigger names a *signal* (an index into `ScopeModel::signals()`), not a
@@ -34,8 +47,34 @@ struct TriggerConfig {
/// dimmer (see `RowSet::persist_alpha()`): 1 -- the default -- keeps nothing but the event on
/// screen, which is the display without persistence, and there is deliberately no second switch
/// for it.
+ ///
+ /// This is the **multiple event** display (the trigger pane's own words for it): a *set of
+ /// retained acquisitions*, one trace per (signal, age). It is not the phosphor density below,
+ /// which is one image and no count -- the two are different displays and the panel offers both.
int persist_depth = 1;
+ /// The **phosphor density** (the plotter's `Plot::set_density()`): off by default, and trigger
+ /// mode only. Where `persist_depth` keeps N whole acquisitions as N traces, this accumulates
+ /// every acquisition into one screen-sized intensity image -- brightness is how often a trace
+ /// passed a cell, so the cost of a display of a thousand acquisitions is the cost of one, and
+ /// there is no depth to get wrong. The image is drawn behind the traces, it is unpickable and it
+ /// reserves no layout space, exactly as the invalid-span bands do.
+ ///
+ /// It applies while an acquisition is on screen -- `Held`, and `Capturing` as it fills -- and
+ /// not on a live view, which is what the user asked for: a live view is the stream, and a
+ /// display of the last few sweeps of it is a *trigger* display. Turning the trigger off (or
+ /// pausing, or opening a file) takes the display away, and a fresh presentation starts from an
+ /// empty image rather than a stale one drawn at another scale.
+ bool density = false;
+ /// Which way it fades (see `PersistDecay`) -- the choice the user asked for, and the panel's
+ /// dropdown. Deliberately one field and not two switches: a density fades one way or the other.
+ PersistDecay density_decay = PersistDecay::Acquisitions;
+ /// `PersistDecay::Time`'s constant, in stream seconds. The step the panel offers is 0.5 s.
+ double density_tau_s = 1.0;
+ /// `PersistDecay::Acquisitions`'s factor. 0.5 -- half per acquisition -- is the schedule a
+ /// phosphor's afterglow has, and the panel does not offer another.
+ double density_factor = 0.5;
+
/// Seconds of post-trigger data to collect before latching the event, given
/// the buffer's span.
double post_span_s(double buffer_span_s) const;
diff --git a/src/ui.cpp b/src/ui.cpp
index 73288fb..bc77641 100644
--- a/src/ui.cpp
+++ b/src/ui.cpp
@@ -3344,6 +3344,91 @@ void update_capture_row(App& app) {
// --- the frame tick ---------------------------------------------------------
+/// The phosphor density's one step for a frame. See the declaration for the rule; what is here is
+/// the two things easy to get wrong.
+///
+/// **The deposit is one frame behind the bind**, and that is deliberate. `Plot::accumulate_density()`
+/// deposits the traces the plot *currently* holds through the transforms of its *last* layout --
+/// which is the only layout there is, since the frame's own is computed inside the paint that
+/// follows this tick. Running the deposit in the same tick that frames an acquisition would
+/// therefore deposit the new snapshot through the *previous* window's transform: right on the
+/// common Held-to-Held transition (both are edge-relative over the same span) and wrong on the
+/// first latch, whose axis was the live ring's. Waiting one frame costs a frame of latency on an
+/// image whose whole subject is the last few seconds.
+///
+/// **The fade's clock is the frame's own wall `dt`.** The plot states the time constant in seconds
+/// and the app hands it the seconds this frame covered: at a steady stream that is the same clock
+/// the display window slides on, and it is what makes the fade "while you watch" rather than "per
+/// acquisition". A stall (a rebuild, a modal dialog, a paused process) is capped at a quarter of a
+/// second, so a step the user did not watch cannot take the whole image out in one go.
+void apply_density(App& app, DisplayMode display, bool live_display) {
+ ScopeModel& model = *app.model;
+ TriggerConfig tc;
+ {
+ std::lock_guard lock(model.mutex());
+ tc = model.trigger();
+ }
+ // On while an acquisition is on screen; a *file* is not a stream of acquisitions at all, and a
+ // live or blank display has nothing for the image to be an image *of*.
+ const bool on =
+ !live_display && (display == DisplayMode::Held || display == DisplayMode::Capturing) &&
+ tc.density;
+ const gint64 now_us = g_get_monotonic_time();
+ double dt = app.density_last_us != 0 ? double(now_us - app.density_last_us) / 1.0e6 : 0.0;
+ app.density_last_us = now_us;
+ dt = std::clamp(dt, 0.0, 0.25);
+ // Switching it on under an acquisition already on screen deposits that one: the alternative is a
+ // switch that appears to do nothing until the next latch, which in `single` mode is for ever.
+ const bool fresh = on && !app.density_was_on;
+ const bool deposit = app.density_pending || fresh;
+ app.density_was_on = on;
+
+ int rows_on = 0;
+ unsigned long long cells = 0;
+ double ink = 0.0;
+ double peak = 0.0;
+ for (int i = 0; i < app.rows->row_count(); ++i) {
+ plot::Plot* p = app.rows->row(i).plot;
+ if (p == nullptr) continue;
+ plot::DensityConfig cfg;
+ cfg.enabled = on;
+ cfg.decay = tc.density_decay == PersistDecay::Time ? plot::DensityDecay::TimeConstant
+ : plot::DensityDecay::PerAcquisition;
+ cfg.time_constant_s = static_cast(tc.density_tau_s);
+ cfg.per_acquisition = static_cast(tc.density_factor);
+ // The plot drops what it held when this turns the display off, so the step below only ever
+ // speaks about an image that is on screen.
+ p->set_density(cfg);
+ if (!on) continue;
+ ++rows_on;
+ // Time first, then the deposit: under `PerAcquisition` the decay is the accumulate path's own,
+ // so this is a no-op there; under `TimeConstant` it is this frame's fade and the deposit lands
+ // on top of it at full ink.
+ p->decay_density(dt);
+ if (deposit) p->accumulate_density();
+ cells += static_cast(p->density_grid().cell_count());
+ ink += p->density_grid().ink();
+ peak = std::max(peak, static_cast(p->density_grid().peak()));
+ }
+ app.density_pending = false;
+ if (rows_on == 0) return; // nothing was on this frame: the dump's figures stay the last real ones
+ app.dbg_density_rows = rows_on;
+ app.dbg_density_cells = cells;
+ app.dbg_density_grid_bytes = cells * sizeof(plot::f32);
+ app.dbg_density_ink = ink;
+ app.dbg_density_peak = peak;
+ ++app.dbg_density_frames;
+ if (app.area != nullptr) {
+ if (const plot::Renderer* r = app.area->renderer()) {
+ // The **most** any one frame uploaded, not the last one's: the exit probe changes the display
+ // before the dump, so the last painted frame is not a frame of this display -- and "the grid
+ // is one byte a cell" is the claim, which a maximum states and a last value does not.
+ const long long uploaded = static_cast(r->stats().density_upload_bytes);
+ app.dbg_density_upload_bytes = std::max(app.dbg_density_upload_bytes, uploaded);
+ }
+ }
+}
+
gboolean tick(gpointer user) {
App* app = static_cast(user);
if (app->window == nullptr) return G_SOURCE_REMOVE;
@@ -3518,6 +3603,10 @@ gboolean tick(gpointer user) {
// Declared outside the lock block because the notice below is a widget write, and the tick's
// widget work happens outside the model's lock.
const bool live_display = display == DisplayMode::Live || !app->waveform_name.empty();
+ // The phosphor density's step for this frame, *before* the bind below: what it deposits is the
+ // acquisition the last frame bound and laid out, which is what the plot's transforms still
+ // describe (see `apply_density()`).
+ apply_density(*app, display, live_display);
int persist_depth = 1;
int persist_want = 0;
@@ -3869,6 +3958,9 @@ gboolean tick(gpointer user) {
// Keep it, at the end of this tick: the record's thumbnail is a picture of the plot, and the plot
// is bound to the event later in this same frame.
app->record_pending = true;
+ // ... and the frame after this one deposits it into every row's density grid, through the
+ // transforms the pin below is about to state (`apply_density()`).
+ app->density_pending = true;
// The window is pinned to exactly what the snapshot captured, read off the
// snapshot itself. Not off `event_span`/`pretrigger_pct`: those say what was
// *asked for*, in stream seconds, while how much the capture actually covers
@@ -5953,6 +6045,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_PERSIST_MS")) {
g_timeout_add(static_cast(std::atoi(env)), persist_probe, &app);
}
+ if (const char* env = g_getenv("UDPSCOPE_PROBE_DENSITY_MS")) {
+ g_timeout_add(static_cast(std::atoi(env)), density_probe, &app);
+ }
// Our own options were consumed by parse_args(); GTK would reject them, so only
// the program name is handed to g_application_run().
@@ -6369,23 +6464,46 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
// different mistakes, and only one of them shows as a missing button.
// The expander's child is **not** in the widget-parent chain: `gtk_widget_get_parent()` on it stays
// NULL even after the window is presented, while `gtk_expander_get_child()` returns it (measured with
- // a throwaway program, all three ways of setting it). So the walk the search widgets are read with has
- // to start at the expander's child and not at the panel -- walking to the panel reads 0/4 on a panel
+ // a throwaway program, all three ways of setting it). So the walk a block's widgets are read with has
+ // to start at that block's own child and not at the panel -- walking to the panel reads 0 on a panel
// that is entirely correct, which is what this line did first.
+ //
+ // Two expanders now: the search block and the Advanced one. The root is taken from **the expander
+ // itself** (`App::search_exp`/`App::trigger_advanced`) rather than from "the first expander in the
+ // panel", which is what this loop used to do -- and which stopped naming the search block the moment
+ // a second one was added before it.
GtkWidget* search_root = app.trigger_body;
- for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_body); c != nullptr;
- c = gtk_widget_get_next_sibling(c)) {
- if (GTK_IS_EXPANDER(c)) {
- if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(c))) search_root = inner;
- break;
+ if (app.search_exp != nullptr) {
+ if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(app.search_exp))) {
+ search_root = inner;
+ }
+ }
+ GtkWidget* advanced_root = app.trigger_body;
+ if (app.trigger_advanced != nullptr) {
+ if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(app.trigger_advanced))) {
+ advanced_root = inner;
}
}
const int search_present =
(app.search_run_button != nullptr) + (app.search_cancel_button != nullptr) +
(app.search_prev_button != nullptr) + (app.search_next_button != nullptr);
+ // The Advanced block's own two readings, for the search block's two reasons: the ids resolving and
+ // the controls being *in* the block. Its controls are the five the density added, and they must be
+ // inside `advanced_root` and not merely non-null -- an id that drifted leaves a control built and
+ // nowhere, which is the failure this pair exists to catch.
+ const int advanced_present =
+ (app.trigger_persist_spin != nullptr) + (app.trigger_density_switch != nullptr) +
+ (app.trigger_density_decay_drop != nullptr) + (app.trigger_density_tau_spin != nullptr) +
+ (app.trigger_density_factor_spin != nullptr);
+ const int advanced_inside =
+ inside(app.trigger_persist_spin, advanced_root) +
+ inside(app.trigger_density_switch, advanced_root) +
+ inside(app.trigger_density_decay_drop, advanced_root) +
+ inside(app.trigger_density_tau_spin, advanced_root) +
+ inside(app.trigger_density_factor_spin, advanced_root);
std::printf(
- "[udpscope] trigger panel: boxes=%d controls_inside=%d/16 search_widgets=%d/4 "
- "search_inside=%d/4\n",
+ "[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 "
+ "search_inside=%d/4 advanced_widgets=%d/5 advanced_inside=%d/5\n",
boxes,
inside(app.trigger_signal_drop, app.trigger_body) +
inside(app.trigger_type_drop, app.trigger_body) +
@@ -6401,11 +6519,11 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
inside(app.trigger_holdoff_spin, app.trigger_body) +
inside(app.trigger_band_spin, app.trigger_body) +
inside(app.trigger_hf_spin, app.trigger_body) +
- inside(app.trigger_persist_spin, app.trigger_body) +
inside(app.events_record_switch, app.trigger_body),
search_present,
inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
- inside(app.search_prev_button, search_root) + inside(app.search_next_button, search_root));
+ inside(app.search_prev_button, search_root) + inside(app.search_next_button, search_root),
+ advanced_present, advanced_inside);
}
{
plot::Plot& p = *rows.row(0).plot;
@@ -6671,6 +6789,19 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
}
std::printf("]\n");
}
+ {
+ // The phosphor density, as of the last tick it was **on** (the exit probe switches the
+ // trigger off before this runs, so the plots hold nothing by now -- see `App::dbg_density_*`).
+ // `frames` is how many ticks drew an image, `cells`/`grid_bytes` its resolution and memory
+ // over the rows it was on, `ink`/`peak` the numbers the plot's own readers report, and
+ // `upload_bytes` the most any one frame uploaded -- one byte a cell, on the frames whose grid
+ // moved; a frame whose grid did not move uploads nothing at all.
+ std::printf("[udpscope] density: frames=%d rows=%d cells=%llu grid_bytes=%llu ink=%.1f "
+ "peak=%.1f upload_bytes=%lld\n",
+ app.dbg_density_frames, app.dbg_density_rows, app.dbg_density_cells,
+ app.dbg_density_grid_bytes, app.dbg_density_ink, app.dbg_density_peak,
+ app.dbg_density_upload_bytes);
+ }
}
return result;
}
diff --git a/src/ui/trigger-panel.ui b/src/ui/trigger-panel.ui
index da9c566..22ec6a9 100644
--- a/src/ui/trigger-panel.ui
+++ b/src/ui/trigger-panel.ui
@@ -209,15 +209,103 @@
-