**`Arm` is now disabled while trigger mode is off**, and labelled `Arm` again: it is the one control in that pane that is *runtime* rather than setting, so with no engine running there is nothing to arm. It is also the only thing in the body that stays disabled — the settings themselves stay editable, which is the point of the previous commit. `arm sensitive with the trigger off=0` is the reading. **`Go live (trigger off)` is deleted.** Its handler was `trigger_toggle → FALSE`, i.e. exactly the toolbar's trigger switch and the pane's own switch, which is what it was for: "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." With that switch now in the pane's own heading, the button was the same control drawn a third time. The status line that told users about it says "or switch the trigger off to stream" instead. The events pane's own `events_live` button is a different button on a different path — the file view restore — and is untouched. **And one silent regression this work uncovered.** The `trigger panel:` frame reading had been walking the *wrapper* box the app puts around the `.ui`'s panel (heading row + locked summary + panel) ever since the heading moved out of the file in the lock commit, so it reported `boxes=2 … search_inside=0/4` against a panel that was entirely correct. It reads `trigger_body` — the file's own panel — again, and now says so in its comment, because a reading that can silently start describing the wrong widget is the failure it exists to catch. Verified: `trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4` (the documented shape, restored); `arm sensitive with the trigger off=0`; `trigger panel sensitive …=1`; `panes:`/`panel switches:`/`pane edits:` unchanged; `make test-ui` 14 files; zero `Gtk-CRITICAL`.
702 lines
35 KiB
C++
702 lines
35 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 {
|
|
|
|
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);
|
|
gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm");
|
|
}
|
|
|
|
/// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists
|
|
/// only the conditions that are implemented, so its index is not `TriggerType`'s value.
|
|
/// 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);
|
|
gtk_button_set_label(GTK_BUTTON(app->arm_button), on ? "Disarm" : "Arm");
|
|
// Arming is a *runtime* control: with trigger mode off there is no engine to arm, and the button is the
|
|
// one part of this pane that is not a setting. (The body itself stays live -- see above.)
|
|
if (app->arm_button != nullptr) gtk_widget_set_sensitive(app->arm_button, on);
|
|
// The pane's own switch shows the same state, whichever of the two was clicked.
|
|
sync_panel_switches(*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);
|
|
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);
|
|
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
|