The plotter caps a density grid at kDensityCellCap now (deps/plotter), so a 2 s window with persistence on no longer scales the image with the window: the probe's four one-signal rows read 1 044 714 cells / 4 178 856 bytes where they read 2 226 150 / 8 904 600, and a moved frame uploads 1 044 714 bytes where it uploaded 2 226 150. The probe's own legs are unchanged (on peak=1.0000, step-acquisitions peak=1.5000, step-time ratio=0.367879). The paint probe no longer counts a frame the density is drawing as *traceless*. `Renderer::draw_traces()` returns while the ink is the image, so every frame of a persistence display stages no trace: the dump read `traceless paints: 653 (637 after the first bind)` for a run whose real figure is 4 (0), and the `[notrace]` line printed for each of those frames. It is now behind UDPSCOPE_PROBE_NOTRACE -- a per-paint line is a log flood and not a reading -- while the counters it details stay in every build, and the line stays reachable for the state it was written for (a bound row that draws nothing). Docs corrected where they stated the old rule: the README, the trigger invariants, the density probe's readings, the verification list (the new variable) and the manual page behind the panel's tooltip.
1008 lines
55 KiB
C++
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 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);
|
|
// The row is kept, not just filled: the contextual help's "trigger_advanced" hint joins it, beside
|
|
// the pre-trigger share -- the panel's finer settings, which is what the ask calls the trigger's
|
|
// advanced ones.
|
|
app.trigger_pre_row = row_for(ui, "pre_row", app.trigger_pre_spin);
|
|
|
|
app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4,
|
|
app.model->config().trigger_holdoff_s);
|
|
row_for(ui, "holdoff_row", app.trigger_holdoff_spin);
|
|
// A band in signal units, on the same scale as the level spins.
|
|
app.trigger_band_spin = spin(0.0, 1.0e12, 0.01, 4, 0.0);
|
|
row_for(ui, "band_row", app.trigger_band_spin);
|
|
// 0 is off. The upper bound is generous; `configure()` clamps to just under the stream's Nyquist,
|
|
// which the panel cannot know.
|
|
app.trigger_hf_spin = spin(0.0, 1.0e7, 1.0, 3, 0.0);
|
|
row_for(ui, "hf_row", app.trigger_hf_spin);
|
|
|
|
const char* modes[] = {trigger_mode_text(TriggerMode::Normal),
|
|
trigger_mode_text(TriggerMode::Single), nullptr};
|
|
app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes);
|
|
row_for(ui, "mode_row", app.trigger_mode_drop);
|
|
|
|
app.events_record_switch = gtk_switch_new();
|
|
gtk_widget_set_valign(app.events_record_switch, GTK_ALIGN_CENTER);
|
|
gtk_switch_set_active(GTK_SWITCH(app.events_record_switch), app.event_recording);
|
|
gtk_widget_set_tooltip_text(app.events_record_switch,
|
|
"keep every triggered acquisition in the event cache, with a "
|
|
"picture of each; the history button goes with it");
|
|
g_signal_connect(app.events_record_switch, "notify::active",
|
|
G_CALLBACK(on_events_record_toggled), &app);
|
|
// Not `row_for()`: that expands its child, which is what a spin button wants and what a switch must not
|
|
// have -- a switch's natural width *is* its size, so an expanded one stretches into a wide pill that
|
|
// reads as a slider. The label takes the slack instead, which is why the file's label carries the
|
|
// `hexpand` on this one row and no other.
|
|
if (GtkWidget* record_row = GTK_WIDGET(gtk_builder_get_object(ui, "record_row"))) {
|
|
gtk_widget_set_halign(app.events_record_switch, GTK_ALIGN_END);
|
|
gtk_widget_set_valign(app.events_record_switch, GTK_ALIGN_CENTER);
|
|
gtk_box_append(GTK_BOX(record_row), app.events_record_switch);
|
|
// Where the contextual help's "events_record" hint joins -- beside the switch, never over it
|
|
// (see `ui_context_help.cpp`). Kept on the App because the layer's table names a field.
|
|
app.trigger_record_row = record_row;
|
|
}
|
|
|
|
// Persistence: how many of the most recent triggered acquisitions stay on screen at once, each
|
|
// older one drawn at half the brightness of the one before it. 1 is the event alone, which is the
|
|
// display without the feature -- so there is no separate switch for it, and its default is what
|
|
// every run had before it existed.
|
|
int persist_seed = 1;
|
|
{
|
|
std::lock_guard<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
|