// 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 #include #include #include namespace udpscope { namespace ui_impl { /// Both arm buttons from the one state, so the panel's and the compact line's cannot disagree about what /// the engine is doing. Three properties, three sources, and they are deliberately not the same source: /// the *label* is the producer's atomic (no lock), the *sensitivity* and the compact button's /// *visibility* are the toolbar toggle's state -- the toggle is what owns `enabled`, and reading it /// instead of the model keeps this callable from a signal handler that does not hold, and must not take, /// 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 = app.trigger_toggle != nullptr && gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle)) != FALSE; const char* label = armed ? "Disarm" : "Arm"; if (app.arm_button != nullptr) { 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); gtk_widget_set_visible(app.trigger_summary_arm, enabled && app.trigger_locked); } } /// 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 lock(app.model->mutex()); const int ch = app.model->trigger().channel; const std::vector& channels = app.model->channels(); if (ch < 0 || ch >= static_cast(channels.size())) return -1; return static_cast(channels[static_cast(ch)].signal); } void on_arm_clicked(GtkButton* button, gpointer user) { App* app = static_cast(user); (void)button; bool armed = false; { // 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; } 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. const int signal = trigger_signal_index(*app); if (armed && signal >= 0) app->model->set_signal_enabled(static_cast(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(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(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 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* /// what the session file stores: an index would tie the file to a declaration order, which is the /// lesson `math_op_at()` already carries, and two vocabularies (one for the UI, one for the file) /// drift apart the moment one of them gains a member. const char* trigger_type_text(TriggerType t) { switch (t) { case TriggerType::Edge: return "edge"; case TriggerType::PulseWidth: return "pulse width"; case TriggerType::Window: return "window"; case TriggerType::Dropout: return "dropout"; } return "edge"; } TriggerType trigger_type_from_text(const std::string& s) { if (s == "pulse width") return TriggerType::PulseWidth; if (s == "window") return TriggerType::Window; if (s == "dropout") return TriggerType::Dropout; return TriggerType::Edge; } const char* trigger_edge_text(TriggerEdge e) { switch (e) { case TriggerEdge::Rising: return "rising"; case TriggerEdge::Falling: return "falling"; case TriggerEdge::Both: return "both"; } return "rising"; } TriggerEdge trigger_edge_from_text(const std::string& s) { if (s == "falling") return TriggerEdge::Falling; if (s == "both") return TriggerEdge::Both; return TriggerEdge::Rising; } const char* width_cmp_text(WidthCmp c) { switch (c) { case WidthCmp::Less: return "<"; case WidthCmp::Greater: return ">"; case WidthCmp::Within: return "within"; case WidthCmp::Outside: return "outside"; } return ">"; } WidthCmp width_cmp_from_text(const std::string& s) { if (s == "<") return WidthCmp::Less; if (s == "within") return WidthCmp::Within; if (s == "outside") return WidthCmp::Outside; return WidthCmp::Greater; } const char* trigger_mode_text(TriggerMode m) { return m == TriggerMode::Single ? "single" : "normal"; } TriggerMode trigger_mode_from_text(const std::string& s) { return s == "single" ? TriggerMode::Single : TriggerMode::Normal; } /// The dropdown's row for a piece of text, or 0. Asked of the widget's own model, so the mapping from /// a stored word to a selection is the list the user sees and not a second table to keep in step. static guint dropdown_index_of(GtkWidget* drop, const char* text) { GListModel* model = drop != nullptr ? gtk_drop_down_get_model(GTK_DROP_DOWN(drop)) : nullptr; const guint n = model != nullptr ? g_list_model_get_n_items(model) : 0; for (guint i = 0; i < n; ++i) { GtkStringObject* item = GTK_STRING_OBJECT(g_list_model_get_item(model, i)); const bool hit = item != nullptr && std::strcmp(gtk_string_object_get_string(item), text) == 0; if (item != nullptr) g_object_unref(item); if (hit) return i; } return 0; } /// The panel's controls from the model: what a session load has just put in the model, so the pane /// shows the trigger it is about to run. Guarded, because a control's own handler publishes what it /// holds and this is the other direction. void write_trigger_widgets(App& app) { if (app.trigger_level_spin == nullptr) return; TriggerConfig tc; { std::lock_guard lock(app.model->mutex()); tc = app.model->trigger(); } const bool was = app.updating_panels; app.updating_panels = true; gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level_spin), tc.condition.level); gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_pre_spin), tc.pretrigger_pct); gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_holdoff_spin), tc.holdoff_s); gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_width_spin), tc.condition.width_s); gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_width2_spin), tc.condition.width2_s); gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level2_spin), tc.condition.level2); 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( std::clamp(tc.persist_depth, 1, kMaxPersistDepth))); } if (app.trigger_density_switch != nullptr) { gtk_switch_set_active(GTK_SWITCH(app.trigger_density_switch), tc.density); } if (app.trigger_density_decay_drop != nullptr) { gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_density_decay_drop), tc.density_decay == PersistDecay::Time ? 1u : 0u); } if (app.trigger_density_tau_spin != nullptr) { gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_density_tau_spin), tc.density_tau_s); } if (app.trigger_density_factor_spin != nullptr) { gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_density_factor_spin), tc.density_factor); } 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))); gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_edge_drop), dropdown_index_of(app.trigger_edge_drop, trigger_edge_text(tc.condition.edge))); gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_cmp_drop), dropdown_index_of(app.trigger_cmp_drop, width_cmp_text(tc.condition.cmp))); gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_mode_drop), dropdown_index_of(app.trigger_mode_drop, trigger_mode_text(tc.mode))); app.updating_panels = was; sync_trigger_rows(app); } /// The compact line: what the pane is set to, in the words the panel's own controls use. Read from the /// model rather than from the widgets, because the widgets are not on screen in this mode and are not /// rebuilt when a session loads. void sync_trigger_summary(App& app) { if (app.trigger_summary == nullptr) return; int row = 0; std::string reading; // the same content as one line, for the dump const auto put = [&](const char* key, const std::string& value) { if (row >= App::kTriggerSummaryRows) return; gtk_label_set_text(GTK_LABEL(app.trigger_summary_keys[row]), key); gtk_label_set_text(GTK_LABEL(app.trigger_summary_values[row]), value.c_str()); gtk_widget_set_visible(app.trigger_summary_keys[row], TRUE); gtk_widget_set_visible(app.trigger_summary_values[row], TRUE); reading += std::string(reading.empty() ? "" : " | ") + key + ": " + value; ++row; }; const auto ms = [](double seconds) { char b[64]; std::snprintf(b, sizeof(b), "%.6g ms", seconds * 1000.0); return std::string(b); }; const auto number = [](double v) { char b[64]; std::snprintf(b, sizeof(b), "%.6g", v); return std::string(b); }; TriggerConfig tc; std::string channel; { std::lock_guard lock(app.model->mutex()); tc = app.model->trigger(); const std::vector& channels = app.model->channels(); if (tc.channel >= 0 && tc.channel < static_cast(channels.size())) { channel = channels[static_cast(tc.channel)].name; } } // With trigger mode off the scope is streaming, and that is the whole of what this pane has to say: // the four lines below describe a trigger that is not running. if (!tc.enabled) { put("mode", "stream (trigger off)"); for (int i = row; i < App::kTriggerSummaryRows; ++i) { gtk_widget_set_visible(app.trigger_summary_keys[i], FALSE); gtk_widget_set_visible(app.trigger_summary_values[i], FALSE); } app.dbg_trigger_summary = reading; gtk_widget_set_visible(app.trigger_summary, app.trigger_locked); return; } put("signal", channel.empty() ? std::string("none acquired") : channel); // The condition: the shape, and whatever qualifies it. A pulse width carries its comparison and its // width, which is what the shape *is*; a window carries its two levels on the line below. std::string condition = trigger_type_text(tc.condition.type); switch (tc.condition.type) { case TriggerType::Edge: case TriggerType::Window: condition += std::string(", ") + trigger_edge_text(tc.condition.edge); break; case TriggerType::PulseWidth: condition += std::string(", ") + width_cmp_text(tc.condition.cmp) + " " + ms(tc.condition.width_s); if (tc.condition.cmp == WidthCmp::Within || tc.condition.cmp == WidthCmp::Outside) { condition += " .. " + ms(tc.condition.width2_s); } break; case TriggerType::Dropout: condition += std::string(", ") + ms(tc.condition.timeout_s); break; } put("condition", condition); switch (tc.condition.type) { case TriggerType::Window: put("level", number(tc.condition.level) + " .. " + number(tc.condition.level2)); break; case TriggerType::Dropout: put("timeout", ms(tc.condition.timeout_s)); break; default: put("level", number(tc.condition.level)); break; } put("mode", trigger_mode_text(tc.mode)); // Everything else only when it is not at its default: a line that says "band 0" is a line that says // nothing, and the four lines above are the ones a user reads. if (tc.condition.noise_band > 0.0 && tc.condition.type != TriggerType::Dropout) { put("noise band", number(tc.condition.noise_band)); } if (tc.condition.hf_fc > 0.0) put("HF reject", number(tc.condition.hf_fc) + " Hz"); if (std::fabs(tc.pretrigger_pct - 50.0) > 1.0e-9) put("pre-trigger", number(tc.pretrigger_pct) + " %"); if (std::fabs(tc.holdoff_s - 0.010) > 1.0e-12) put("hold-off", ms(tc.holdoff_s)); for (int i = row; i < App::kTriggerSummaryRows; ++i) { gtk_widget_set_visible(app.trigger_summary_keys[i], FALSE); gtk_widget_set_visible(app.trigger_summary_values[i], FALSE); } app.dbg_trigger_summary = reading; gtk_widget_set_visible(app.trigger_summary, app.trigger_locked); } 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)); const int persist = static_cast(std::lround(gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_persist_spin)))); // The density's four controls. Guarded on null, unlike the ones above: they are built by this // panel and a `.ui` that lost one leaves the field at its default rather than crashing the // publish -- and the switch's own state is what a session file and the tick then agree on. const bool density = app->trigger_density_switch != nullptr && gtk_switch_get_active(GTK_SWITCH(app->trigger_density_switch)) != FALSE; const guint density_decay = app->trigger_density_decay_drop != nullptr ? gtk_drop_down_get_selected(GTK_DROP_DOWN(app->trigger_density_decay_drop)) : 0; const double density_tau = app->trigger_density_tau_spin != nullptr ? gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_density_tau_spin)) : 1.0; const double density_factor = app->trigger_density_factor_spin != nullptr ? gtk_spin_button_get_value(GTK_SPIN_BUTTON(app->trigger_density_factor_spin)) : 0.5; { 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; tc.density = density; tc.density_decay = density_decay == 1u ? PersistDecay::Time : PersistDecay::Acquisitions; tc.density_tau_s = density_tau; tc.density_factor = density_factor; } // 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 /// 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); } } // The density's own rows follow the *display*, not the condition: the two fades take different // numbers, so only the one in use is on screen -- a greyed spin for the other is a field the user // cannot tell from a broken one -- and the whole choice is hidden while the display is off, since // a decay kind for an image that is not being drawn has no subject. The switch itself stays: it is // the one control that turns the display on. const bool density_on = app.trigger_density_switch != nullptr && gtk_switch_get_active(GTK_SWITCH(app.trigger_density_switch)) != FALSE; const bool density_by_time = app.trigger_density_decay_drop != nullptr && gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_density_decay_drop)) == 1u; show(app.trigger_density_decay_row, density_on); show(app.trigger_density_tau_row, density_on && density_by_time); show(app.trigger_density_factor_row, density_on && !density_by_time); } /// The **density switch**. It publishes the field and nothing else: whether the image is on is /// read every frame from the model, so there is no widget state to keep in step, and the tick /// notices the transition itself -- which is what deposits the acquisition already on screen /// instead of waiting for the next latch (in `single` mode there may never be one). void on_density_toggled(GObject* o, GParamSpec*, gpointer user) { (void)o; publish_trigger_fields(static_cast(user)); } /// The **fade** dropdown. Publishes, then re-states the block: which of the two parameter rows is /// on screen is this dropdown's business and `sync_trigger_rows()` is the one place that reads it. void on_density_decay_changed(GObject* o, GParamSpec*, gpointer user) { (void)o; App* app = static_cast(user); publish_trigger_fields(app); sync_trigger_rows(*app); } 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); } // The panel's body stays *live* whether or not the trigger is running: a trigger is configured // before it is switched on, and switching it off to edit it -- or to check what it is set to -- // is the ordinary way to work. (It used to go insensitive with the mode, which left the settings // unreachable exactly when a user wanted to change them.) What that costs is the reading: the // `trigger panel sensitive` line is now a regression guard, and a `0` there means the // configuration has been locked out again. // 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); // Arming is a *runtime* control: with trigger mode off there is no engine to arm, and the arm buttons // are the one part of this pane that is not a setting. (The body itself stays live -- see above.) Both // of them are stated from this switch's new state in one place, so they cannot disagree with each other. sync_arm_buttons(*app); // The pane's own switch shows the same state, whichever of the two was clicked, and the compact line // changes shape with it: with trigger mode off it says "stream" and nothing else. sync_panel_switches(*app); sync_trigger_summary(*app); } /// 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. /// 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); // The *body* is what trigger-off disables; this panel's index into it is `trigger_body`. app.trigger_body = box; 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[] = {trigger_type_text(TriggerType::Edge), trigger_type_text(TriggerType::PulseWidth), trigger_type_text(TriggerType::Window), trigger_type_text(TriggerType::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[] = {trigger_edge_text(TriggerEdge::Rising), trigger_edge_text(TriggerEdge::Falling), trigger_edge_text(TriggerEdge::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[] = {width_cmp_text(WidthCmp::Less), width_cmp_text(WidthCmp::Greater), width_cmp_text(WidthCmp::Within), width_cmp_text(WidthCmp::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); // The row is kept, not just filled: the contextual help's "trigger_advanced" hint joins it, beside // the pre-trigger share -- the panel's finer settings, which is what the ask calls the trigger's // advanced ones. app.trigger_pre_row = 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[] = {trigger_mode_text(TriggerMode::Normal), trigger_mode_text(TriggerMode::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); // Where the contextual help's "events_record" hint joins -- beside the switch, never over it // (see `ui_context_help.cpp`). Kept on the App because the layer's table names a field. 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 lock(app.model->mutex()); persist_seed = app.model->persist_depth_locked(); } app.trigger_persist_spin = spin(1.0, static_cast(kMaxPersistDepth), 1.0, 0, static_cast(persist_seed)); gtk_widget_set_tooltip_text( app.trigger_persist_spin, "multiple events: how many of the most recent triggered acquisitions stay on screen as " "separate traces, 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); // The phosphor *density*: one bounded image per trace, in that trace's own colour, every // acquisition accumulated into it, drawn *in place of* the traces and fading by a time constant // or by a per-acquisition factor -- the other display of the same acquisitions, and nothing like // the multiple-event one above (an image per signal against N retained traces, and no count to // choose). It applies in trigger mode only, which the body already is: the whole block goes // insensitive with the trigger off, the panel's own rule. app.trigger_density_switch = gtk_switch_new(); gtk_widget_set_valign(app.trigger_density_switch, GTK_ALIGN_CENTER); gtk_widget_set_halign(app.trigger_density_switch, GTK_ALIGN_END); { std::lock_guard lock(app.model->mutex()); gtk_switch_set_active(GTK_SWITCH(app.trigger_density_switch), app.model->trigger().density); } gtk_widget_set_tooltip_text( app.trigger_density_switch, "accumulate every triggered acquisition into an image per signal, in that " "signal's own colour, drawn in place of the traces: brightness is how often a trace passed, " "so an image of a thousand acquisitions costs what one costs. Trigger mode only, and cleared " "whenever the display leaves it"); g_signal_connect(app.trigger_density_switch, "notify::active", G_CALLBACK(on_density_toggled), &app); // Not `row_for()`: it expands its child, and an expanded switch is a wide pill that reads as a // slider rather than as a switch -- the record row's rule, and the label carries the `hexpand`. if (GtkWidget* density_row = GTK_WIDGET(gtk_builder_get_object(ui, "density_row"))) { gtk_box_append(GTK_BOX(density_row), app.trigger_density_switch); app.trigger_density_row = density_row; } const char* density_decays[] = {"acquisitions", "time", nullptr}; app.trigger_density_decay_drop = gtk_drop_down_new_from_strings(density_decays); gtk_widget_set_tooltip_text( app.trigger_density_decay_drop, "'acquisitions': every acquisition multiplies the image by the factor below, so the N-th " "acquisition back is the factor to the N -- it scales with the trigger rate. 'time': the " "image fades by exp(-dt / the time constant) as the display runs, whatever the rate"); app.trigger_density_decay_row = row_for(ui, "density_decay_row", app.trigger_density_decay_drop); g_signal_connect(app.trigger_density_decay_drop, "notify::selected", G_CALLBACK(on_density_decay_changed), &app); app.trigger_density_tau_spin = spin(0.01, 3600.0, 0.1, 3, 1.0); gtk_widget_set_tooltip_text(app.trigger_density_tau_spin, "seconds for the image to fall to 1/e of its ink: the trace is still " "visible a second afterwards, faintly, which is what a phosphor does"); app.trigger_density_tau_row = row_for(ui, "density_tau_row", app.trigger_density_tau_spin); app.trigger_density_factor_spin = spin(0.0, 1.0, 0.05, 2, 0.5); gtk_widget_set_tooltip_text(app.trigger_density_factor_spin, "what each acquisition keeps of the ones before it: 0.5 is the " "schedule a phosphor's afterglow has -- 1, 0.5, 0.25, ... -- and 0 " "shows only the newest acquisition"); app.trigger_density_factor_row = row_for(ui, "density_factor_row", app.trigger_density_factor_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_persist_spin, app.trigger_density_tau_spin, app.trigger_density_factor_spin}) { g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app); } // The advanced block itself, so the probe can read it back and the dump can walk into it: an // expander's child is *not* in the widget-parent chain (see docs/claude/invariants-gtk-and-chrome.md), // so "is this control in the panel" has to start from the child the expander returns. app.trigger_advanced = GTK_WIDGET(gtk_builder_get_object(ui, "advanced_exp")); 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")); app.search_exp = GTK_WIDGET(gtk_builder_get_object(ui, "search_exp")); // The expander's header box, where the contextual help's "trigger_search" hint joins as its last // child (see `ui_context_help.cpp`): a hint on the block's own child would leave it unreachable // while the block is collapsed. app.trigger_search = GTK_WIDGET(gtk_builder_get_object(ui, "search_head")); 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); // The heading row, and the switch that is the *same* control as the header bar's button. Built here // rather than in the file because it must not go insensitive with the body: turning trigger mode // back on would otherwise mean finding the toolbar button that greyed the panel out. GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); gtk_widget_set_margin_top(head, 8); gtk_widget_set_margin_start(head, 8); gtk_widget_set_margin_end(head, 8); // The row the contextual help's "?" for this pane joins (see `ui_context_help.cpp`): a sibling of // the title and the controls, so a help button can never cover one of them. It is also one of the // surfaces the help's annotated figures label, which is why the pointer outlives this function. app.trigger_head = head; GtkWidget* title = gtk_label_new("Trigger"); gtk_widget_set_halign(title, GTK_ALIGN_START); gtk_widget_set_hexpand(title, TRUE); gtk_box_append(GTK_BOX(head), title); // What the pane is set to, for the mode where its settings are not on screen. app.trigger_summary = gtk_label_new(""); gtk_widget_add_css_class(app.trigger_summary, "dim-label"); gtk_label_set_ellipsize(GTK_LABEL(app.trigger_summary), PANGO_ELLIPSIZE_END); gtk_widget_set_halign(app.trigger_summary, GTK_ALIGN_START); gtk_widget_set_hexpand(app.trigger_summary, TRUE); gtk_widget_set_visible(app.trigger_summary, FALSE); gtk_box_append(GTK_BOX(head), app.trigger_summary); app.trigger_edit = gtk_button_new_from_icon_name("document-edit-symbolic"); gtk_widget_add_css_class(app.trigger_edit, "flat"); gtk_widget_set_valign(app.trigger_edit, GTK_ALIGN_CENTER); gtk_widget_set_tooltip_text(app.trigger_edit, "configure the trigger"); g_signal_connect(app.trigger_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app); gtk_box_append(GTK_BOX(head), app.trigger_edit); // The compact line's own arm control, built here beside the [edit] and the switch for the same reason // the switch is: this row stays on screen when the body is hidden. A locked pane is exactly where // arming is decided -- the body's button left with the body -- and the heading is where it has to be. app.trigger_summary_arm = gtk_button_new_with_label("Arm"); gtk_widget_set_valign(app.trigger_summary_arm, GTK_ALIGN_CENTER); gtk_widget_set_tooltip_text(app.trigger_summary_arm, "arm or disarm the trigger; disarming freezes the view where it stands"); gtk_widget_set_visible(app.trigger_summary_arm, FALSE); g_signal_connect(app.trigger_summary_arm, "clicked", G_CALLBACK(on_arm_clicked), &app); gtk_box_append(GTK_BOX(head), app.trigger_summary_arm); app.trigger_switch = gtk_switch_new(); gtk_widget_set_valign(app.trigger_switch, GTK_ALIGN_CENTER); gtk_widget_set_tooltip_text(app.trigger_switch, "trigger mode, the same switch the header bar carries"); g_signal_connect(app.trigger_switch, "notify::active", G_CALLBACK(on_panel_switch_toggled), &app); gtk_box_append(GTK_BOX(head), app.trigger_switch); // The locked pane's content: the settings as a definition list, so the answer to "what is this set to" // is four short lines rather than a row of controls. app.trigger_summary = gtk_grid_new(); gtk_grid_set_row_spacing(GTK_GRID(app.trigger_summary), 2); gtk_grid_set_column_spacing(GTK_GRID(app.trigger_summary), 12); gtk_widget_set_margin_top(app.trigger_summary, 6); gtk_widget_set_margin_bottom(app.trigger_summary, 6); gtk_widget_set_margin_start(app.trigger_summary, 8); gtk_widget_set_margin_end(app.trigger_summary, 8); for (int i = 0; i < App::kTriggerSummaryRows; ++i) { app.trigger_summary_keys[i] = gtk_label_new(""); gtk_widget_add_css_class(app.trigger_summary_keys[i], "dim-label"); gtk_widget_set_halign(app.trigger_summary_keys[i], GTK_ALIGN_START); app.trigger_summary_values[i] = gtk_label_new(""); gtk_widget_set_halign(app.trigger_summary_values[i], GTK_ALIGN_START); gtk_label_set_ellipsize(GTK_LABEL(app.trigger_summary_values[i]), PANGO_ELLIPSIZE_END); gtk_grid_attach(GTK_GRID(app.trigger_summary), app.trigger_summary_keys[i], 0, i, 1, 1); gtk_grid_attach(GTK_GRID(app.trigger_summary), app.trigger_summary_values[i], 1, i, 1, 1); } gtk_widget_set_visible(app.trigger_summary, FALSE); GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); gtk_box_append(GTK_BOX(outer), head); gtk_box_append(GTK_BOX(outer), app.trigger_summary); gtk_box_append(GTK_BOX(outer), box); return outer; } } // namespace ui_impl } // namespace udpscope