// 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 #include namespace udpscope { namespace ui_impl { void on_arm_clicked(GtkButton* button, gpointer user) { App* app = static_cast(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 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(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 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(user)); } /// The same publish, for the two dropdowns. It needs its own entry point because /// `notify::` 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(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(user); publish_trigger_fields(app); sync_trigger_rows(*app); } void on_trigger_signal_changed(GObject* obj, GParamSpec*, gpointer user) { App* app = static_cast(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 lock(app->model->mutex()); if (sel >= app->model->channels().size()) return; app->model->trigger().channel = static_cast(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(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 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(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(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(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(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 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(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(user); if (a->search.prev()) search_jump(*a, a->search.hits()[static_cast( 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(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( 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(u)); }), &app); } sync_trigger_rows(app); return box; } } // namespace ui_impl } // namespace udpscope