trigger: a phosphor display keeps the last N acquisitions; and a Force button

Two changes in the trigger pane, landed together.

**Persistence.** Trigger mode keeps the last N published acquisitions and draws them fading toward
the background, N being a panel control (1..8, default 1) with N=1 meaning today's behaviour
exactly, so the knob is the switch. The layers are the accumulate path's own -- one layer per
(signal, age) in the row's free trace slots, named with the accumulate vocabulary so
`layer_base_name()` strips the suffix and `signal_color()` resolves the base signal -- so a layer is
not a signal: the signal list, the colours and the measurements see one row per signal.
`ScopeModel` retains the outgoing snapshot in seven atomic `shared_ptr` slots (no lock, no copy; the
snapshot already exists) and a layer's channel is resolved **by name inside the snapshot**, because
the channel list is renumbered by any structural change since that acquisition was cut.
`RowSet::sync_layers()` re-plans from the rows and the depth alone, so a latch costs no rebuild, and
`event_data_window()` skips layer slots so an older acquisition can never re-pin the axis. The
schedule is 0.5^age -- the acquisition on screen at full brightness and each older one half the
previous, independent of N, so raising the depth adds dimmer acquisitions rather than rescaling the
ones already on screen. Memory is bounded by the ring's own `--buffer-mb` rather than by the depth,
which is the bound that cannot be driven into an OOM, and the panel says when either bound bites.
This also wired up `set_trace_alpha()`, which the accumulate path was setting and nothing was
reading -- so the documented 0.35 translucency of an accumulation was opaque in fact.

**Force.** In the trigger pane beside Arm/Disarm, shown only while trigger mode is on: one
acquisition now, with the configured window. It is a synthetic edge rather than a second publisher
-- the engine opens one capture at the present moment and everything else is the ordinary latch, so
a forced acquisition is cut, pinned, framed, recorded and cached exactly like a latched one, and
the button owns no window and no publish. The pre-trigger gate is deliberately skipped (capture now
is a request for the history that exists, and the snapshot reports its own shorter pre_s), a
capture already filling is refused rather than restarted, and the state afterwards is the mode's:
Normal stays armed, Single disarms. The request is one atomic flag written under the model mutex
with `armed` -- the producer reads `armed` at the top of a packet and the request at the bottom, so
a request written between the two would be dropped -- and consumed at the end of a packet, after
the channel loop and before the pending publish.

**A defect fixed in passing, in the function Force was modelled on:** `on_arm_clicked()` passed
`TriggerConfig::channel` (an index into `channels()`) to `set_signal_enabled()` (an index into
`signals()`). With one signal acquired, arming switched on the stream's clock signal and left the
watched signal untouched -- and since a signal the panel was not showing is a change, it rebuilt
the buffer under the click, dropping the very pre-trigger window the arming, and now the Force, was
about to capture. Measured before: `0.0100 s of history behind it`; after: `2.5299 s`.
`trigger_signal_index()` is now the one resolution, used by both buttons.
This commit is contained in:
Martino Ferrari committed 2026-10-16 16:46:17 +02:00
1 parent fc4700d1c9
commit e14e1ee069
22 files changed
+1652 -26

No files matched your search

+131 -3
View File
@@ -5,6 +5,8 @@
#include "ui_app.hpp"
#include <algorithm>
#include <cmath>
#include <cstring>
#include <gtk/gtk.h>
@@ -22,6 +24,13 @@ namespace ui_impl {
/// the model lock. Visibility is `enabled && locked`: with trigger mode off the line's own text says
/// "stream (trigger off)" and there is no engine to arm, and with the pane open the body's button is the
/// one on screen -- never both at once.
///
/// It states the Force button too, from the same `enabled`, and that is the *only* place either
/// button's availability is decided: a trigger that is off has no engine to force, and one that is
/// on can be forced whether or not it is armed -- in Single mode the previous acquisition leaves the
/// engine disarmed, which is exactly when a user reaches for Force again, so a Force that grays out
/// with the engine's armed state would be unavailable for the one thing it is for. Force carries no
/// state of its own, so nothing else about it can drift.
void sync_arm_buttons(App& app) {
const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed);
const bool enabled =
@@ -32,6 +41,9 @@ void sync_arm_buttons(App& app) {
gtk_button_set_label(GTK_BUTTON(app.arm_button), label);
gtk_widget_set_sensitive(app.arm_button, enabled);
}
if (app.force_button != nullptr) {
gtk_widget_set_sensitive(app.force_button, enabled);
}
if (app.trigger_summary_arm != nullptr) {
gtk_button_set_label(GTK_BUTTON(app.trigger_summary_arm), label);
gtk_widget_set_sensitive(app.trigger_summary_arm, enabled);
@@ -39,11 +51,28 @@ void sync_arm_buttons(App& app) {
}
}
/// The **signal** the trigger's channel carries, or -1 when the trigger has no channel at all.
///
/// `TriggerConfig::channel` is an index into `ScopeModel::channels()` -- the *acquired* ones, which
/// `rebuild_channels()` renumbers -- while `ScopeModel::set_signal_enabled()` indexes the stream's
/// `signals()`. Passing one for the other is not a near miss: with one signal acquired, channel 0 is
/// that signal while signal 0 is the stream's *clock*, so arming switched on the time base and left
/// the signal the trigger watches exactly as it was -- and a signal the panel was not showing *is* a
/// change, so the click also rebuilt the buffer and dropped its history, which is the pre-trigger
/// window the arming (or the force) was about to capture. The two indices agree only in the one case
/// where every signal is acquired in order.
int trigger_signal_index(App& app) {
std::lock_guard<std::mutex> lock(app.model->mutex());
const int ch = app.model->trigger().channel;
const std::vector<ChannelInfo>& channels = app.model->channels();
if (ch < 0 || ch >= static_cast<int>(channels.size())) return -1;
return static_cast<int>(channels[static_cast<std::size_t>(ch)].signal);
}
void on_arm_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
(void)button;
bool armed = false;
int signal = -1;
{
// The producer reads (and, on a single-shot latch, writes) these fields under
// the same lock; an unguarded read-modify-write here can lose its update.
@@ -51,7 +80,6 @@ void on_arm_clicked(GtkButton* button, gpointer user) {
TriggerConfig& tc = app->model->trigger();
tc.armed = !tc.armed;
armed = tc.armed;
signal = tc.channel;
}
if (armed) {
app->event_held = false;
@@ -61,10 +89,67 @@ void on_arm_clicked(GtkButton* button, gpointer user) {
// Arming is a statement that this signal should be watched, so its acquisition
// is switched on with it -- otherwise the trigger could never see a sample.
// Outside the lock: set_signal_enabled() takes it.
const int signal = trigger_signal_index(*app);
if (armed && signal >= 0) app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
sync_arm_buttons(*app);
}
/// **Force**: one acquisition, now, with the pre/post window the panel is set to -- what a scope's
/// Force button does when the condition is not coming, and the reason it does not wait for one.
///
/// It states no state of its own. The capture it opens goes through the engine's ordinary latch,
/// which is what cuts the window, publishes the snapshot, counts it and applies the mode's re-arm
/// rule, so the two buttons above read afterwards exactly as that latch left them: still "Disarm" in
/// Normal mode (the trigger goes back to waiting for the next edge), and "Arm" in Single mode, where
/// one acquisition is the whole of what the mode means whether an edge produced it or this click.
void on_force_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
(void)button;
// The signal the trigger watches, resolved from its channel -- `set_signal_enabled()` indexes
// `signals()` while `TriggerConfig::channel` indexes `channels()` (see the helper) -- and refused
// up front when there is none, see the refusal below.
const int signal = trigger_signal_index(*app);
if (signal < 0) {
// The trigger is pointed at nothing -- no signal is being acquired, which the panel's own
// dropdown says ("(no acquired channels)") -- so there is no channel to take an acquisition
// from. Refused here and not left to the producer, which never looks at a frame at all for a
// model with no *streamed* channel: a request made in this state would sit behind that early
// return and fire on the first signal switched on afterwards, which is an acquisition the user
// asked for minutes ago.
std::fprintf(stderr,
"[udpscope] trigger: force refused -- no signal is being acquired to trigger on; "
"switch one on in the signal list\n");
std::fflush(stderr);
return;
}
// A force is an instruction to acquire the signal the trigger watches, so its acquisition is
// switched on with it -- exactly as arming switches it on, and *before* the request below: the
// enable rebuilds the channel list (which is what gives the trigger's signal a channel at all),
// and a packet landing between the two would otherwise consume the request on a trigger whose
// channel is still -1, and drop it. It is a no-op when the signal is already acquired, which is
// what keeps a force from clearing the very history it is about to capture.
app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
{
// The model lock, for both writes and together: the producer holds it for a whole packet --
// reading the panel's `armed` at the top of one and consuming the request at the bottom -- so a
// request written outside it could be read by a packet whose engine was still disarmed, and is
// then dropped rather than replayed.
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
// A disarmed engine has nothing to acquire with. In Single mode the previous acquisition left it
// disarmed, and "capture now" is the whole of what a user means by pressing Force there, so the
// click arms it -- the same field the arm button writes, which the producer's next packet turns
// into an armed engine and a new latch count.
tc.armed = true;
if (app->acq != nullptr) app->acq->request_force();
}
// Nothing is cleared here on purpose: the held event stays on screen until the coming capture is
// bound, one packet later, rather than flashing back to live in between. The frame tick clears it
// itself when it sees the capture open (`new_capture`), which is also where the axis is pinned to
// the requested window for the sweep.
sync_arm_buttons(*app);
}
/// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists
/// only the conditions that are implemented, so its index is not `TriggerType`'s value.
/// The trigger's four enumerations as words, and back. These are the panel's dropdown labels *and*
@@ -156,6 +241,13 @@ void write_trigger_widgets(App& app) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_timeout_spin), tc.condition.timeout_s);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_band_spin), tc.condition.noise_band);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_hf_spin), tc.condition.hf_fc);
// Clamped here as well as in the setter: a session file may carry any number, and the spin's own
// range would refuse one outside it.
if (app.trigger_persist_spin != nullptr) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_persist_spin),
static_cast<double>(
std::clamp(tc.persist_depth, 1, kMaxPersistDepth)));
}
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_type_drop),
dropdown_index_of(app.trigger_type_drop,
trigger_type_text(tc.condition.type)));
@@ -299,6 +391,8 @@ void publish_trigger_fields(App* app) {
const double timeout = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_timeout_spin));
const double band = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_band_spin));
const double hf = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_hf_spin));
const int persist =
static_cast<int>(std::lround(gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_persist_spin))));
{
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
@@ -318,6 +412,11 @@ void publish_trigger_fields(App* app) {
tc.condition.noise_band = band;
tc.condition.hf_fc = hf;
}
// The persistence depth is applied through the one setter rather than written into the config
// here: it is clamped to the cap *and* it prunes the retained acquisitions at once, so lowering
// it while the trigger is quiet -- when no latch will come along to apply it -- still frees the
// memory. It takes the model lock itself, hence outside the block above.
app->model->set_persist_depth(persist);
}
/// Shows only the rows the selected condition reads, so the panel never offers a figure the
@@ -572,15 +671,44 @@ GtkWidget* build_trigger_panel(App& app) {
app.trigger_record_row = record_row;
}
// Persistence: how many of the most recent triggered acquisitions stay on screen at once, each
// older one drawn at half the brightness of the one before it. 1 is the event alone, which is the
// display without the feature -- so there is no separate switch for it, and its default is what
// every run had before it existed.
int persist_seed = 1;
{
std::lock_guard<std::mutex> lock(app.model->mutex());
persist_seed = app.model->persist_depth_locked();
}
app.trigger_persist_spin = spin(1.0, static_cast<double>(kMaxPersistDepth), 1.0, 0,
static_cast<double>(persist_seed));
gtk_widget_set_tooltip_text(
app.trigger_persist_spin,
"how many of the most recent triggered acquisitions stay on screen, each older one at half "
"the previous one's brightness; 1 is the event alone. A layer needs a free trace slot in the "
"row that plots its signal, and the retained acquisitions are also bounded by the buffer's "
"own size");
app.trigger_persist_row = row_for(ui, "persistence_row", app.trigger_persist_spin);
app.arm_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_arm"));
if (app.arm_button != nullptr) {
g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app);
}
// Beside the arm button, and in the *body* rather than in the compact heading with it: the heading's
// one control is the runtime *state* a locked pane exists to change, while a force is an action
// whose subject -- the condition, the level, the pre/post split and the mode that decides whether
// one acquisition is all of it -- is in the body. A second Force in the heading would be a second
// control for one action whose settings are off screen, and the heading already carries [edit],
// Arm/Disarm and the mode switch.
app.force_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_force"));
if (app.force_button != nullptr) {
g_signal_connect(app.force_button, "clicked", G_CALLBACK(on_force_clicked), &app);
}
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin,
app.trigger_width_spin, app.trigger_width2_spin,
app.trigger_level2_spin, app.trigger_timeout_spin, app.trigger_band_spin,
app.trigger_hf_spin}) {
app.trigger_hf_spin, app.trigger_persist_spin}) {
g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app);
}
g_signal_connect(app.trigger_edge_drop, "notify::selected",