Files
udpscope/src/ui_trigger.cpp
T
Martino Ferrari 1baa58b8fd feat: the help is a submenu, with a contextual-help layer and a guided tour
The hamburger menu's help rows were three flat entries and a window. They are a **`Help` submenu**
now, which is where every GNOME menu keeps them, with the four things a first run cannot find:

  - Keyboard shortcuts   Ctrl+/   (the existing overview, `show_shortcuts()`)
  - Documentation        F1      (the existing manual, `show_help()`)
  - Guided tour          Ctrl+T  (new)
  - Enable contextual help        (new, a stateful row: its check *is* the setting)

The submenu *replaces* the menu's rows rather than floating beside them, the same shape and the same
reason as the plot's right-click menu: a submenu torn out of the menu it belongs to is a second thing
to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the moment anything
else closed its popover. It is the pages of a `GtkStack`, not a rebuild of the box and not a swap of
the popover's child: the main view holds `app.layout_toggle`, which the tick's `sync_layout_editor()`
and the `L` accel read, so rebuilding would leave that pointer on a destroyed widget, and a swapped
child would leave the view that is not showing without an owner.

**The contextual help is one table and a "?" per surface** (`src/ui_context_help.cpp`): the plot, the
trigger, the cursors, the measurements, the math channels, the signals, the event history, the time
window, the status line, and the math editor's identity row. Each row names the *heading box* its
button joins -- a sibling of the title, never an overlay child on a corner, which is what would cover
the switch or the [edit] button already there -- so adding a surface is a row and a widget pointer.
Off means *gone*, not greyed: the buttons are removed, and they are found by a data key rather than a
list of pointers because the math editor's heading is built afresh on every opening and a list would
go stale one dialog later. `contextual_help` and `tour_seen` are keys of `settings.toml`, written
through the same `Settings`/`load_settings()`/`save_settings()` the Preferences dialog uses, whose own
rows start from `load_settings()` and so carry them through untouched.

**The guided tour is five steps on a `GtkOverlay`** above the window's content: the plot, the side
panel, the trigger, the signals and the status line. There is no popover in it and therefore no pointer
grab to leak -- four dim rectangles around the hole are what make it modal -- and the probe asserts
that as a reading (`controllers=` against the count taken before it opened) rather than as a claim.
Everything is placed from `gtk_widget_compute_bounds()` re-read on every frame, because a widget's
allocation is 0 on the frame it is first shown and a window resize moves it under a one-shot
measurement; the hole is kept from the last frame that could measure the target, because a frame that
lands between two layout passes would otherwise empty it and make the spotlight jump. `Skip`, `Finish`
and `Escape` all hide every child and leave nothing behind.

A first run gets the tour once, decided by `tour_seen` and not by "the settings file did not exist" (a
user who changed the theme has a file and has still never seen it), and the flag is written when the
tour *starts* rather than when it finishes: a user who skips it in the first second has still seen it.

`UDPSCOPE_PROBE_HELPUI_MS` drives all of it -- the auto-start, the menu's seven rows and the
submenu's five, the toggle's effect read as a count of affordances on either side of it, and the tour
started, stepped forwards and back and dismissed -- and the exit dump gains `help layer:` and
`help figures:`, the latter being the ten surfaces' window-coordinate rectangles that the manual's
annotated screenshots crop their boxes from. Two of them are what the D-Bus-free headless run can
still not decide, and both are limitations rather than defects: export carries no "?" (it is a menu
action with no surface of its own, and a popover inside the menu's popover is the nested-popover trap
the invariants already warn about), and the tour card cannot miss its target when the target is the
whole content -- which is what the plot is at the window's 756 px minimum, where the split view
overlays the side panel instead of putting it beside the plot. The probe prints both rectangles
rather than asserting a placement, so that case reads as the geometry it is.

Also fixed while it was found: `gtk_widget_measure()` includes the widget's own margins (measured: a
280 px box with `margin-start 453` reports 733), so a measure that is used to *place* the widget is a
feedback loop -- that is what made the tour card walk between the corner and the target from frame to
frame.
2026-10-14 17:02:29 +02:00

743 lines
38 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 <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.
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.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);
}
}
void on_arm_clicked(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
(void)button;
bool armed = false;
int signal = -1;
{
// The producer reads (and, on a single-shot latch, writes) these fields under
// the same lock; an unguarded read-modify-write here can lose its update.
std::lock_guard<std::mutex> lock(app->model->mutex());
TriggerConfig& tc = app->model->trigger();
tc.armed = !tc.armed;
armed = tc.armed;
signal = tc.channel;
}
if (armed) {
app->event_held = false;
app->capturing = false;
app->model->trigger_time_valid().store(false, std::memory_order_relaxed);
}
// Arming is a statement that this signal should be watched, so its acquisition
// is switched on with it -- otherwise the trigger could never see a sample.
// Outside the lock: set_signal_enabled() takes it.
if (armed && signal >= 0) app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
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);
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));
{
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;
}
}
/// Shows only the rows the selected condition reads, so the panel never offers a figure the
/// condition ignores. The widgets keep their values while hidden: `EventCondition` carries
/// all eight fields whatever its type, and switching away and back must not lose a setting.
void sync_trigger_rows(App& app) {
if (app.trigger_type_drop == nullptr) return;
const TriggerType type =
trigger_type_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_type_drop)));
const WidthCmp cmp =
width_cmp_at(gtk_drop_down_get_selected(GTK_DROP_DOWN(app.trigger_cmp_drop)));
const bool pulse = type == TriggerType::PulseWidth;
const bool window = type == TriggerType::Window;
// The rows come from the panel's `.ui`, so a missing one is a mistake there rather than an impossibility
// here: `show()` names that by doing nothing, and `[udpscope] trigger panel:` is what proves the row
// exists.
const auto show = [](GtkWidget* row, bool visible) {
if (row != nullptr) gtk_widget_set_visible(row, visible);
};
show(app.trigger_cmp_row, pulse);
show(app.trigger_width_row, pulse);
show(app.trigger_width2_row, pulse && (cmp == WidthCmp::Within || cmp == WidthCmp::Outside));
show(app.trigger_level2_row, window);
show(app.trigger_timeout_row, type == TriggerType::Dropout);
// A band widens the comparator every type but Dropout compares with; a dropout is an absence and a
// band cannot describe one. The HF reject sits before every comparison, so it applies to all types.
// The rows come from the file and hold their spin, so the spin's parent *is* the row.
show(gtk_widget_get_parent(app.trigger_band_spin), type != TriggerType::Dropout);
show(gtk_widget_get_parent(app.trigger_hf_spin), true);
// A band of zero width reduces the condition to a float-equality test on a real-valued
// signal, which is unusable: it fires on the exact value and nothing else, which in practice
// is never. The panel is where that is refused: the second level is pushed at least one
// increment clear of the first, in whichever direction it already lay.
if (window) {
GtkSpinButton* l2 = GTK_SPIN_BUTTON(app.trigger_level2_spin);
const double level = gtk_spin_button_get_value(GTK_SPIN_BUTTON(app.trigger_level_spin));
double step = 0.0;
gtk_spin_button_get_increments(l2, &step, nullptr);
const double other = gtk_spin_button_get_value(l2);
if (std::fabs(other - level) < step) {
gtk_spin_button_set_value(l2, other < level ? level - step : level + step);
}
}
}
void on_trigger_field_changed(GtkWidget*, gpointer user) {
publish_trigger_fields(static_cast<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 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<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);
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);
}
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);
}
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin,
app.trigger_width_spin, app.trigger_width2_spin,
app.trigger_level2_spin, app.trigger_timeout_spin, app.trigger_band_spin,
app.trigger_hf_spin}) {
g_signal_connect(w, "value-changed", G_CALLBACK(on_trigger_field_changed), &app);
}
g_signal_connect(app.trigger_edge_drop, "notify::selected",
G_CALLBACK(on_trigger_drop_changed), &app);
g_signal_connect(app.trigger_mode_drop, "notify::selected",
G_CALLBACK(on_trigger_drop_changed), &app);
// Searching *is* the trigger's condition applied to history, so it belongs in this panel
// rather than in one of its own: a second place to spell the same condition would be a
// second place to get it wrong. Collapsed by default -- it is not part of running a trigger.
// Its widgets come from the same file, and only their signals are wired here.
app.search_run_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_run"));
app.search_cancel_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_cancel"));
app.search_status_label = GTK_WIDGET(gtk_builder_get_object(ui, "search_status"));
app.search_prev_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_prev"));
app.search_pos_label = GTK_WIDGET(gtk_builder_get_object(ui, "search_pos"));
app.search_next_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_next"));
app.search_marks_label = GTK_WIDGET(gtk_builder_get_object(ui, "search_marks"));
if (app.search_run_button != nullptr) {
g_signal_connect(app.search_run_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
App* a = static_cast<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