// 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)); 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)); { 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; tc.condition.noise_band = band; tc.condition.hf_fc = hf; } } /// 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; // The rows come from the panel's `.ui`, so a missing one is a mistake there rather than an impossibility // here: `show()` names that by doing nothing, and `[udpscope] trigger panel:` is what proves the row // exists. const auto show = [](GtkWidget* row, bool visible) { if (row != nullptr) gtk_widget_set_visible(row, visible); }; show(app.trigger_cmp_row, pulse); show(app.trigger_width_row, pulse); show(app.trigger_width2_row, pulse && (cmp == WidthCmp::Within || cmp == WidthCmp::Outside)); show(app.trigger_level2_row, window); show(app.trigger_timeout_row, type == TriggerType::Dropout); // A band widens the comparator every type but Dropout compares with; a dropout is an absence and a // band cannot describe one. The HF reject sits before every comparison, so it applies to all types. // The rows come from the file and hold their spin, so the spin's parent *is* the row. show(gtk_widget_get_parent(app.trigger_band_spin), type != TriggerType::Dropout); show(gtk_widget_get_parent(app.trigger_hf_spin), true); // 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); } // Disabled, not hidden: a panel you can still see is a panel you can read, and the settings you // are about to switch back on must not vanish while you look for them. gtk_widget_set_sensitive(app->trigger_panel, 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); } /// The row the `.ui` declares for a control, with the control appended to it. /// /// The label is the file's, because it is text a translator owns; the control is code's, because a /// dropdown's model and a spin button's range are the application's. `labeled_box()` did both in code and /// this is what replaces it, row by row. `nullptr` comes back when the file has no such row: the control is /// then built and never placed, which is what `[udpscope] trigger panel:` reads. GtkWidget* row_for(GtkBuilder* ui, const char* id, GtkWidget* child) { GtkWidget* row = GTK_WIDGET(gtk_builder_get_object(ui, id)); if (row == nullptr) return nullptr; gtk_widget_set_hexpand(child, true); gtk_box_append(GTK_BOX(row), child); return row; } GtkWidget* build_trigger_panel(App& app) { // The panel's frame -- the heading, one labelled row per setting, the two buttons and the search block -- // is a UI file. What is not is every control that carries a value: a dropdown's model and a spin button's // range are the application's, so they are built here and appended into the file's rows. `make test-ui` // checks the file's types against the GIR; the ids below are the only thing that keeps the code and the // file agreeing on the names. GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/trigger-panel.ui"); GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_panel")); if (box == nullptr) return gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); 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); row_for(ui, "signal_row", 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"); row_for(ui, "condition_row", app.trigger_type_drop); const char* edges[] = {"rising", "falling", "both", nullptr}; app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges); row_for(ui, "edge_row", 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 = row_for(ui, "cmp_row", app.trigger_cmp_drop); app.trigger_width_spin = spin(0.0, 3600.0, 0.001, 6, 0.001); app.trigger_width_row = row_for(ui, "width_row", app.trigger_width_spin); app.trigger_width2_spin = spin(0.0, 3600.0, 0.001, 6, 0.002); app.trigger_width2_row = row_for(ui, "width2_row", app.trigger_width2_spin); // 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 = row_for(ui, "level2_row", app.trigger_level2_spin); app.trigger_timeout_spin = spin(0.0, 3600.0, 0.001, 6, 0.001); app.trigger_timeout_row = row_for(ui, "timeout_row", app.trigger_timeout_spin); 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); row_for(ui, "level_row", app.trigger_level_spin); app.trigger_pre_spin = spin(0.0, 99.0, 1.0, 0, 50.0); row_for(ui, "pre_row", app.trigger_pre_spin); app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4, app.model->config().trigger_holdoff_s); row_for(ui, "holdoff_row", app.trigger_holdoff_spin); // A band in signal units, on the same scale as the level spins. app.trigger_band_spin = spin(0.0, 1.0e12, 0.01, 4, 0.0); row_for(ui, "band_row", app.trigger_band_spin); // 0 is off. The upper bound is generous; `configure()` clamps to just under the stream's Nyquist, // which the panel cannot know. app.trigger_hf_spin = spin(0.0, 1.0e7, 1.0, 3, 0.0); row_for(ui, "hf_row", app.trigger_hf_spin); const char* modes[] = {"normal", "single", nullptr}; app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes); row_for(ui, "mode_row", 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 `row_for()`: 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, which is why the file's label carries the // `hexpand` on this one row and no other. if (GtkWidget* record_row = GTK_WIDGET(gtk_builder_get_object(ui, "record_row"))) { 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(record_row), app.events_record_switch); } 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); } GtkWidget* live = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_live")); if (live != nullptr) g_signal_connect(live, "clicked", G_CALLBACK(on_go_live), &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}) { 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. // Its widgets come from the same file, and only their signals are wired here. app.search_run_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_run")); app.search_cancel_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_cancel")); app.search_status_label = GTK_WIDGET(gtk_builder_get_object(ui, "search_status")); app.search_prev_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_prev")); app.search_pos_label = GTK_WIDGET(gtk_builder_get_object(ui, "search_pos")); app.search_next_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_next")); app.search_marks_label = GTK_WIDGET(gtk_builder_get_object(ui, "search_marks")); if (app.search_run_button != nullptr) { 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; double dt = 0.0; { std::lock_guard lock(a->model->mutex()); c = a->model->trigger().condition; channel = a->model->trigger().channel; dt = a->model->partition().slot_dt(); } a->search.start(c, channel, dt); update_search_panel(*a); }), &app); } if (app.search_cancel_button != nullptr) { 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); } if (app.search_prev_button != nullptr) { 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); } if (app.search_next_button != nullptr) { 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); } 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