Files
udpscope/src/ui_trigger.cpp
T

433 lines
21 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The trigger panel and everything the trigger change reaches: the condition rows it shows and hides\n// per shape, the arming, and the switch between the stream and a stored event.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <string>
namespace udpscope {
namespace ui_impl {
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.
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
tc.armed = !tc.armed;
armed = tc.armed;
signal = tc.channel;
}
if (armed) {
app->event_held = false;
app->capturing = false;
app->model->trigger_time_valid().store(false, std::memory_order_relaxed);
}
// 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.
if (armed && signal >= 0) app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm");
}
/// 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.
TriggerType trigger_type_at(guint i) {
switch (i) {
case 1:
return TriggerType::PulseWidth;
case 2:
return TriggerType::Window;
case 3:
return TriggerType::Dropout;
default:
return TriggerType::Edge;
}
}
void publish_trigger_fields(App* app) {
if (app->updating_panels) return;
// Read the widgets first (they may be touched again by a re-entrant handler if a
// setter ran while the lock was held), then publish the values under the lock the
// producer reads them with.
const double level = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_level_spin));
const double pre = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_pre_spin));
const double holdoff = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_holdoff_spin));
const guint edge = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_edge_drop));
const guint mode = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_mode_drop));
const guint type = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_type_drop));
const guint cmp = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_cmp_drop));
const double width = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width_spin));
const double width2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_width2_spin));
const double level2 = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_level2_spin));
const double timeout = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_timeout_spin));
{
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
tc.condition.level = level;
tc.pretrigger_pct = pre;
tc.holdoff_s = holdoff;
tc.condition.edge = edge == 0 ? TriggerEdge::Rising
: edge == 1 ? TriggerEdge::Falling
: TriggerEdge::Both;
tc.mode = mode == 0 ? TriggerMode::Normal : TriggerMode::Single;
tc.condition.type = trigger_type_at(type);
tc.condition.cmp = width_cmp_at(cmp);
tc.condition.width_s = width;
tc.condition.width2_s = width2;
tc.condition.level2 = level2;
tc.condition.timeout_s = timeout;
}
}
/// Shows only the rows the selected condition reads, so the panel never offers a figure the
/// condition ignores. The widgets keep their values while hidden: `EventCondition` carries
/// all eight fields whatever its type, and switching away and back must not lose a setting.
void sync_trigger_rows(App& app) {
if (app.trigger_type_drop == nullptr) return;
const TriggerType type =
trigger_type_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_type_drop)));
const WidthCmp cmp =
width_cmp_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_cmp_drop)));
const bool pulse = type == TriggerType::PulseWidth;
const bool window = type == TriggerType::Window;
gtk_widget_set_visible(app.trigger_cmp_row, pulse);
gtk_widget_set_visible(app.trigger_width_row, pulse);
gtk_widget_set_visible(app.trigger_width2_row,
pulse && (cmp == WidthCmp::Within || cmp == WidthCmp::Outside));
gtk_widget_set_visible(app.trigger_level2_row, window);
gtk_widget_set_visible(app.trigger_timeout_row, type == TriggerType::Dropout);
// A band of zero width reduces the condition to a float-equality test on a real-valued
// signal, which is unusable: it fires on the exact value and nothing else, which in practice
// is never. The panel is where that is refused: the second level is pushed at least one
// increment clear of the first, in whichever direction it already lay.
if (window) {
GtkSpinButton* l2 = GTK_SPIN_BUTTON(app.trigger_level2_spin);
const double level = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app.trigger_level_spin));
double step = 0.0;
gtk_spin_button_get_increments(l2, &step, nullptr);
const double other = gtk_spin_button_get_value(l2);
if (std::fabs(other - level) < step) {
gtk_spin_button_set_value(l2, other < level ? level - step : level + step);
}
}
}
void on_trigger_field_changed(GtkWidget*, gpointer user) {
publish_trigger_fields(static_cast<App*>(user));
}
/// The same publish, for the two dropdowns. It needs its own entry point because
/// `notify::<prop>` passes a `GParamSpec*` ahead of the user data while
/// `GtkSpinButton::value-changed` does not: a two-parameter callback on a `notify::`
/// signal reads the pspec as its `App*` and crashes on the first field it touches.
void on_trigger_drop_changed(GObject*, GParamSpec*, gpointer user) {
publish_trigger_fields(static_cast<App*>(user));
}
/// The two dropdowns that change *which rows exist*, as opposed to a value within them.
/// Same `notify::selected` signature trap as `on_trigger_drop_changed`.
void on_trigger_shape_changed(GObject*, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
publish_trigger_fields(app);
sync_trigger_rows(*app);
}
void on_trigger_signal_changed(GObject* obj, GParamSpec*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->updating_panels) return;
(void)obj;
const guint sel = gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_signal_drop));
if (sel == GTK_INVALID_LIST_POSITION) return;
{
std::lock_guard<std::mutex> lock(app->model->mutex());
if (sel >= app->model->channels().size()) return;
app->model->trigger().channel = static_cast<int>(sel);
}
app->trigger_level_touched = false; // a new signal gets a fresh default level
}
void on_trigger_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const bool on = gtk_toggle_button_get_active(button);
int signal = -1;
app->model->set_trigger_enabled(on); // takes the lock and mirrors the atomic
{
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
tc.armed = on; // trigger mode means "wait for an event", so it starts armed
signal = tc.channel;
}
app->event_held = false;
app->capturing = false;
if (!on) {
app->model->trigger_time_valid().store(false, std::memory_order_relaxed);
// The epoch is *not* written here: the frame tick owns it and states the live answer -- minus
// the newest acquired sample -- after it has bound and acquired, which is the only moment the
// right number is knowable. This used to set it to 0 ("seconds since the stream started"),
// which is no longer a display coordinate anything uses. Nothing is left stale in the
// meantime: the group follows the live edge again from here, and `Figure::layout()` re-derives
// the range from it on every frame.
app->rows->set_time_window(app->rows->time_window()); // and follow the live edge
}
if (on && signal >= 0) {
app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
}
gtk_revealer_set_reveal_child(GTK_REVEALER(app->trigger_revealer), on);
// The event-history button is part of trigger mode and the pane it opens is *on demand*; leaving
// trigger mode takes the button away and collapses the pane, so going back to streaming is not a
// column of stale events beside the plot. Recording is the other half of that answer, so this is
// `sync_events_availability()`'s call and not the trigger's alone.
sync_events_availability(*app);
gtk_button_set_label(GTK_BUTTON(app->arm_button), on ? "Disarm" : "Arm");
}
/// The way out of trigger mode, from inside the panel that froze the view: it is
/// the toolbar's trigger switch, so there is exactly one piece of state.
void on_go_live(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), FALSE);
}
GtkWidget* build_trigger_panel(App& app) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_margin_top(box, 8);
gtk_widget_set_margin_bottom(box, 8);
gtk_widget_set_margin_start(box, 8);
gtk_widget_set_margin_end(box, 8);
GtkWidget* heading = gtk_label_new("Trigger");
gtk_widget_set_halign(heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(box), heading);
app.trigger_signal_drop = gtk_drop_down_new(G_LIST_MODEL(gtk_string_list_new(nullptr)), nullptr);
g_signal_connect(app.trigger_signal_drop, "notify::selected",
G_CALLBACK(on_trigger_signal_changed), &app);
gtk_box_append(GTK_BOX(box), labeled_box("signal", app.trigger_signal_drop));
const char* types[] = {"edge", "pulse width", "window", "dropout", nullptr};
app.trigger_type_drop = gtk_drop_down_new_from_strings(types);
gtk_widget_set_tooltip_text(app.trigger_type_drop,
"what counts as an event; the fields below change to suit it");
gtk_box_append(GTK_BOX(box), labeled_box("condition", app.trigger_type_drop));
const char* edges[] = {"rising", "falling", "both", nullptr};
app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges);
gtk_box_append(GTK_BOX(box), labeled_box("edge", app.trigger_edge_drop));
const char* cmps[] = {"<", ">", "within", "outside", nullptr};
app.trigger_cmp_drop = gtk_drop_down_new_from_strings(cmps);
gtk_widget_set_tooltip_text(app.trigger_cmp_drop,
"how the measured pulse width is compared: '<' is the glitch "
"trigger -- a pulse shorter than it should be");
app.trigger_cmp_row = labeled_box("width is", app.trigger_cmp_drop);
gtk_box_append(GTK_BOX(box), app.trigger_cmp_row);
app.trigger_width_spin = spin(0.0, 3600.0, 0.001, 6, 0.001);
app.trigger_width_row = labeled_box("width s", app.trigger_width_spin);
gtk_box_append(GTK_BOX(box), app.trigger_width_row);
app.trigger_width2_spin = spin(0.0, 3600.0, 0.001, 6, 0.002);
app.trigger_width2_row = labeled_box("and s", app.trigger_width2_spin);
gtk_box_append(GTK_BOX(box), app.trigger_width2_row);
// Zero, not the range's minimum: the level this row reads is unset until a channel is chosen,
// and the panel's sync writes the declared range's middle then -- but a dialog that opens at
// -1e12 is a number nobody typed.
app.trigger_level2_spin = spin(-1.0e12, 1.0e12, 0.01, 4, 0.0);
gtk_widget_set_tooltip_text(app.trigger_level2_spin,
"the band's other edge; it is kept at least one increment away "
"from the level, because a band of zero width is not a band: the "
"condition becomes an exact float-equality test against the level, "
"which an ordinary signal never satisfies and a signal sitting at "
"the level satisfies on every transition");
app.trigger_level2_row = labeled_box("to level", app.trigger_level2_spin);
gtk_box_append(GTK_BOX(box), app.trigger_level2_row);
app.trigger_timeout_spin = spin(0.0, 3600.0, 0.001, 6, 0.001);
app.trigger_timeout_row = labeled_box("timeout s", app.trigger_timeout_spin);
gtk_box_append(GTK_BOX(box), app.trigger_timeout_row);
app.trigger_level_spin = spin(-1.0e12, 1.0e12, 0.01, 4, 0.0);
g_signal_connect(app.trigger_level_spin, "value-changed", G_CALLBACK(+[](GtkWidget*, gpointer u) {
static_cast<App*>(u)->trigger_level_touched = true;
}),
&app);
gtk_box_append(GTK_BOX(box), labeled_box("level", app.trigger_level_spin));
app.trigger_pre_spin = spin(0.0, 99.0, 1.0, 0, 50.0);
gtk_box_append(GTK_BOX(box), labeled_box("pre-trigger %", app.trigger_pre_spin));
app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4,
app.model->config().trigger_holdoff_s);
gtk_box_append(GTK_BOX(box), labeled_box("hold-off s", app.trigger_holdoff_spin));
const char* modes[] = {"normal", "single", nullptr};
app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes);
gtk_box_append(GTK_BOX(box), labeled_box("mode", app.trigger_mode_drop));
app.events_record_switch = gtk_switch_new();
gtk_widget_set_valign(app.events_record_switch, GTK_ALIGN_CENTER);
gtk_switch_set_active(GTK_SWITCH(app.events_record_switch), app.event_recording);
gtk_widget_set_tooltip_text(app.events_record_switch,
"keep every triggered acquisition in the event cache, with a "
"picture of each; the history button goes with it");
g_signal_connect(app.events_record_switch, "notify::active",
G_CALLBACK(on_events_record_toggled), &app);
// Not `labeled_box()`: that expands its child, which is what a spin button wants and what a switch
// must not have -- a switch's natural width *is* its size, so an expanded one stretches into a wide
// pill that reads as a slider. The label takes the slack instead.
{
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget* text = gtk_label_new("record events");
gtk_widget_set_halign(text, GTK_ALIGN_START);
gtk_widget_set_hexpand(text, TRUE);
gtk_box_append(GTK_BOX(row), text);
gtk_widget_set_halign(app.events_record_switch, GTK_ALIGN_END);
gtk_widget_set_valign(app.events_record_switch, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(row), app.events_record_switch);
gtk_box_append(GTK_BOX(box), row);
}
app.arm_button = gtk_button_new_with_label("Arm");
g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app);
gtk_box_append(GTK_BOX(box), app.arm_button);
GtkWidget* live = gtk_button_new_with_label("Go live (trigger off)");
gtk_widget_set_tooltip_text(live, "disable trigger mode and stream continuously");
g_signal_connect(live, "clicked", G_CALLBACK(on_go_live), &app);
gtk_box_append(GTK_BOX(box), live);
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}) {
g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app);
}
g_signal_connect(app.trigger_edge_drop, "notify::selected",
G_CALLBACK(on_trigger_drop_changed), &app);
g_signal_connect(app.trigger_mode_drop, "notify::selected",
G_CALLBACK(on_trigger_drop_changed), &app);
// Searching *is* the trigger's condition applied to history, so it belongs in this panel
// rather than in one of its own: a second place to spell the same condition would be a
// second place to get it wrong. Collapsed by default -- it is not part of running a trigger.
GtkWidget* search_exp = gtk_expander_new("Search the buffer");
gtk_widget_set_tooltip_text(search_exp,
"find every place in the retained buffer where the condition "
"above holds, and step through them");
GtkWidget* sbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_margin_top(sbox, 4);
GtkWidget* srun = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
app.search_run_button = gtk_button_new_with_label("Search");
gtk_widget_set_hexpand(app.search_run_button, true);
g_signal_connect(app.search_run_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
// Published, not read back off the widgets: the producer's copy is the
// one the live trigger uses, so the search asks exactly its question.
publish_trigger_fields(a);
EventCondition c;
int channel = -1;
{
std::lock_guard<std::mutex> lock(a->model->mutex());
c = a->model->trigger().condition;
channel = a->model->trigger().channel;
}
a->search.start(c, channel);
update_search_panel(*a);
}),
&app);
gtk_box_append(GTK_BOX(srun), app.search_run_button);
app.search_cancel_button = gtk_button_new_with_label("Stop");
gtk_widget_set_sensitive(app.search_cancel_button, false);
g_signal_connect(app.search_cancel_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
a->search.cancel();
update_search_panel(*a);
}),
&app);
gtk_box_append(GTK_BOX(srun), app.search_cancel_button);
gtk_box_append(GTK_BOX(sbox), srun);
app.search_status_label = gtk_label_new("not searched");
gtk_widget_set_halign(app.search_status_label, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(app.search_status_label), true);
gtk_widget_add_css_class(app.search_status_label, "dim-label");
gtk_box_append(GTK_BOX(sbox), app.search_status_label);
GtkWidget* snav = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
app.search_prev_button = gtk_button_new_from_icon_name("go-previous-symbolic");
gtk_widget_set_tooltip_text(app.search_prev_button, "the previous hit");
gtk_widget_set_sensitive(app.search_prev_button, false);
g_signal_connect(app.search_prev_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
if (a->search.prev()) search_jump(*a, a->search.hits()[static_cast<std::size_t>(
a->search.cursor())]);
update_search_panel(*a);
}),
&app);
gtk_box_append(GTK_BOX(snav), app.search_prev_button);
app.search_pos_label = gtk_label_new("--");
gtk_widget_set_hexpand(app.search_pos_label, true);
gtk_box_append(GTK_BOX(snav), app.search_pos_label);
app.search_next_button = gtk_button_new_from_icon_name("go-next-symbolic");
gtk_widget_set_tooltip_text(app.search_next_button, "the next hit");
gtk_widget_set_sensitive(app.search_next_button, false);
g_signal_connect(app.search_next_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<App*>(user);
// The first press with no cursor goes to the first hit, not the second.
if (a->search.cursor() < 0) {
a->search.select(0);
} else if (!a->search.next()) {
update_search_panel(*a);
return;
}
search_jump(*a, a->search.hits()[static_cast<std::size_t>(
a->search.cursor())]);
update_search_panel(*a);
}),
&app);
gtk_box_append(GTK_BOX(snav), app.search_next_button);
gtk_box_append(GTK_BOX(sbox), snav);
app.search_marks_label = gtk_label_new(" ");
gtk_widget_set_halign(app.search_marks_label, GTK_ALIGN_START);
gtk_widget_add_css_class(app.search_marks_label, "dim-label");
// Ellipsize so the label's minimum width is bounded: without this, the longest text
// (~58 chars) would widen the side pane just as `search_status_label` would without its own
// wrap — the same fix the statistics label carries at line 2780.
gtk_label_set_ellipsize(GTK_LABEL(app.search_marks_label), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(sbox), app.search_marks_label);
gtk_expander_set_child(GTK_EXPANDER(search_exp), sbox);
gtk_box_append(GTK_BOX(box), search_exp);
g_signal_connect(app.trigger_type_drop, "notify::selected",
G_CALLBACK(on_trigger_shape_changed), &app);
g_signal_connect(app.trigger_cmp_drop, "notify::selected",
G_CALLBACK(on_trigger_shape_changed), &app);
// The clamp lives in `sync_trigger_rows()`, so the two level spins have to run it rather
// than the plain field publish.
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_level2_spin}) {
g_signal_connect(w, "value-changed", G_CALLBACK(+[](GtkWidget*, gpointer u) {
sync_trigger_rows(*static_cast<App*>(u));
}),
&app);
}
sync_trigger_rows(app);
return box;
}
} // namespace ui_impl
} // namespace udpscope