Files
udpscope/src/ui_trigger.cpp
T

1008 lines
55 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 <algorithm>
#include <cmath>
#include <cstring>
#include <gtk/gtk.h>
#include <string>
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<std::mutex> lock(app.model->mutex());
const int ch = app.model->trigger().channel;
const std::vector<ChannelInfo>& channels = app.model->channels();
if (ch < 0 || ch >= static_cast<int>(channels.size())) return -1;
return static_cast<int>(channels[static_cast<std::size_t>(ch)].signal);
}
void on_arm_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
(void)button;
bool armed = false;
{
// 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;
}
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<std::size_t>(signal), true);
sync_arm_buttons(*app);
}
/// **Force**: one acquisition, now, with the pre/post window the panel is set to -- what a scope's
/// Force button does when the condition is not coming, and the reason it does not wait for one.
///
/// It states no state of its own. The capture it opens goes through the engine's ordinary latch,
/// which is what cuts the window, publishes the snapshot, counts it and applies the mode's re-arm
/// rule, so the two buttons above read afterwards exactly as that latch left them: still "Disarm" in
/// Normal mode (the trigger goes back to waiting for the next edge), and "Arm" in Single mode, where
/// one acquisition is the whole of what the mode means whether an edge produced it or this click.
void on_force_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
(void)button;
// The signal the trigger watches, resolved from its channel -- `set_signal_enabled()` indexes
// `signals()` while `TriggerConfig::channel` indexes `channels()` (see the helper) -- and refused
// up front when there is none, see the refusal below.
const int signal = trigger_signal_index(*app);
if (signal < 0) {
// The trigger is pointed at nothing -- no signal is being acquired, which the panel's own
// dropdown says ("(no acquired channels)") -- so there is no channel to take an acquisition
// from. Refused here and not left to the producer, which never looks at a frame at all for a
// model with no *streamed* channel: a request made in this state would sit behind that early
// return and fire on the first signal switched on afterwards, which is an acquisition the user
// asked for minutes ago.
std::fprintf(stderr,
"[udpscope] trigger: force refused -- no signal is being acquired to trigger on; "
"switch one on in the signal list\n");
std::fflush(stderr);
return;
}
// A force is an instruction to acquire the signal the trigger watches, so its acquisition is
// switched on with it -- exactly as arming switches it on, and *before* the request below: the
// enable rebuilds the channel list (which is what gives the trigger's signal a channel at all),
// and a packet landing between the two would otherwise consume the request on a trigger whose
// channel is still -1, and drop it. It is a no-op when the signal is already acquired, which is
// what keeps a force from clearing the very history it is about to capture.
app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
{
// The model lock, for both writes and together: the producer holds it for a whole packet --
// reading the panel's `armed` at the top of one and consuming the request at the bottom -- so a
// request written outside it could be read by a packet whose engine was still disarmed, and is
// then dropped rather than replayed.
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
// A disarmed engine has nothing to acquire with. In Single mode the previous acquisition left it
// disarmed, and "capture now" is the whole of what a user means by pressing Force there, so the
// click arms it -- the same field the arm button writes, which the producer's next packet turns
// into an armed engine and a new latch count.
tc.armed = true;
if (app->acq != nullptr) app->acq->request_force();
}
// Nothing is cleared here on purpose: the held event stays on screen until the coming capture is
// bound, one packet later, rather than flashing back to live in between. The frame tick clears it
// itself when it sees the capture open (`new_capture`), which is also where the axis is pinned to
// the requested window for the sweep.
sync_arm_buttons(*app);
}
/// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists
/// only the conditions that are implemented, so its index is not `TriggerType`'s value.
/// The trigger's four enumerations as words, and back. These are the panel's dropdown labels *and*
/// 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<std::mutex> 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<double>(
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<std::mutex> lock(app.model->mutex());
tc = app.model->trigger();
const std::vector<ChannelInfo>& channels = app.model->channels();
if (tc.channel >= 0 && tc.channel < static_cast<int>(channels.size())) {
channel = channels[static_cast<std::size_t>(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<int>(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<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;
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<App*>(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<App*>(user);
publish_trigger_fields(app);
sync_trigger_rows(*app);
}
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);
}
// 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 dock's Events block and the `events` page follow what is recorded or cached, not trigger mode
// alone: leaving trigger mode does not empty the cache, and a replay is not in trigger mode at all.
// 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<App*>(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 dock's Events block and page follow 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<std::mutex> lock(app.model->mutex());
persist_seed = app.model->persist_depth_locked();
}
app.trigger_persist_spin = spin(1.0, static_cast<double>(kMaxPersistDepth), 1.0, 0,
static_cast<double>(persist_seed));
gtk_widget_set_tooltip_text(
app.trigger_persist_spin,
"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<std::mutex> 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<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;
double dt = 0.0;
{
std::lock_guard<std::mutex> 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<App*>(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<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);
}
if (app.search_next_button != nullptr) {
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);
}
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);
// 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