Files
udpscope/src/ui_layout.cpp
T
Martino Ferrari 974d6aa2d9 trigger: a phosphor persistence, driven by the plotter's density image
The trigger pane gains the display the user asked for and the library now provides, and the
persistence it already had is renamed to what it is.

**Persistence is one image; the last-N-traces display is "multiple events".** Both live in a new
`Advanced` expander in the trigger panel, together with the settings that are set up once and left
alone. `persistence` (a switch) turns on the plotter's `Plot::set_density()` for every row:
every triggered acquisition is accumulated into a screen-sized intensity grid drawn behind the
traces, brightness being how many acquisitions covered a cell -- no depth to choose, no memory
that grows with the acquisitions (2.2 MB over four 573x412 rows in the probe's run). `fades by`
picks the fade the user asked for: **acquisitions** (each keeps a factor of what was before it,
0.5 by default -- the phosphor afterglow) or **time** (a time constant in stream seconds, so the
display fades while you watch whatever the trigger rate).

- **Trigger mode only, as asked.** `apply_density()` (the tick) states the display on for `Held`
  and `Capturing` and off for `Live`, `Waiting`, `Paused` and a file, and turning it off drops the
  grid at once rather than at an acquisition a quiet trigger never sends. It is unpickable and
  reserves no layout space, exactly as the invalid-span bands are: a density can never be in the
  way of a cursor or a drop, and it cannot move the framing.
- **One deposit per acquisition, one frame after the frame that frames it.** `Plot::accumulate_density()`
  deposits what the plot holds through the transforms of its *last* layout -- the frame's own is
  computed inside the paint that follows the tick -- so `density_pending` is set beside
  `record_pending` and consumed by the next tick. In the same tick it was right on a Held-to-Held
  transition and wrong on the first latch, whose axis was still the live ring's. Switching the
  display on under an acquisition already on screen deposits it too, so the switch does not appear
  to do nothing until the next latch (`single` mode may never send one).
- **The session carries it**: `trigger_density`, `trigger_density_decay` ("acquisitions"/"time",
  the words the panel shows), `trigger_density_tau` and `trigger_density_factor`, written whether
  or not it is on -- and `trigger_persistence` still means the multiple-event depth, so an older
  file is unchanged.
- **The library changed twice under this**: the deposit now counts a cell **once per trace per
  sweep** (counting the hits put one dense sweep at the ceiling of the brightness scale and hid the
  sweeps behind it -- the probe's `on` leg read `peak=18` for a single sweep and reads `peak=1`
  now), and a frame's upload is every row's rather than the last row's. Both are in the plotter's
  own commit.

`UDPSCOPE_PROBE_DENSITY_MS` reads it all back off the plots -- the plotter's own `ink()`/`peak()` --
with the panel's own widgets driving the switch and the fade, in four legs: **off** (`rows=0
ink=0.0 peak=0.0000`), **on** (`rows=4`, `peak=1.0000`), **step-acquisitions** (`peak=1.0000 ->
1.5000`, the factor then the new sweep) and **step-time** (`ratio=0.367879`, `1/e` for `dt = tau`).
It also reads the frame cost as an A/B **inside one run** (the accumulators at the switch against
the frames after it): `tick 0.74 -> 9.99 ms`, `draw 2.09 -> 20.91 ms` at four rows and 2.2 M cells
on llvmpipe.

Acceptance: clean build 0 errors and exactly the three documented warnings (12352-byte GResource
blob, two `MathNode::source`), `test-ui` 13/13, `test-help` green, a plain run with **0
Gtk-CRITICAL / Gtk-WARNING** and `gtk warnings: 0`, and the panel's own frame reading
`boxes=16 controls_inside=15/15 advanced_widgets=5/5 advanced_inside=5/5` (the block's controls
counted from the expander's child, which is not in the widget-parent chain -- and the search
block's walk now names *its* expander instead of "the first one", which the second expander would
have stolen).

Docs: the trigger/pinning invariants carry the rule and its five consequences, `probe-readings.md`
the four legs and the dump line, `verification.md` the probe's sequence and assertions, and the
manual's trigger page explains both displays to a user.
2026-10-16 17:28:54 +02:00

591 lines
28 KiB
C++

// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The layout editor and the persisted session: the overlay buttons that add and remove rows, the\n// debounced save, and the configurations dialog with the layouts it loads and stores.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <string>
namespace udpscope {
namespace ui_impl {
/// Reads the session layout back out of the model and rows: the display window,
/// each row's slot signal names, and each signal's calibration.
LayoutState collect_layout(App& app) {
LayoutState st;
st.window_s = app.rows->time_window();
ScopeModel& model = *app.model;
for (int i = 0; i < app.rows->row_count(); ++i) {
LayoutState::Row row;
const RowSet::Row& r = app.rows->row(i);
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t si = static_cast<std::size_t>(s);
// A persistence layer is written as an *empty* slot: it is the display's own artefact, not
// the user's arrangement, and a name like `sine (A)` read back would be a slot the next
// session could not resolve -- the same reason a group's extra layers are stripped before
// they are re-derived.
row.signals.push_back(r.layer[si] > 0 ? std::string()
: r.signal_name[si]);
}
const std::vector<float>& weights = app.rows->row_weights();
if (static_cast<std::size_t>(i) < weights.size()) row.weight = weights[static_cast<std::size_t>(i)];
st.rows.push_back(std::move(row));
}
for (std::size_t si = 0; si < model.signals().size(); ++si) {
for (std::size_t c = 0; c < model.channels().size(); ++c) {
const ChannelInfo& ch = model.channels()[c];
if (ch.signal != si) continue;
st.calibration.push_back({model.signals()[si].name, ch.gain, ch.offset});
break;
}
}
// The *enable* state, not just what is plotted: a signal can be enabled only as a math channel's
// source, and a session that came back with it off would come back with a NaN trace. "Enabled" is
// read off the channels the model built -- it has no per-signal accessor and does not need one,
// since an enabled signal is exactly one that produced a channel.
std::vector<bool> on(model.signals().size(), false);
for (const ChannelInfo& ch : model.channels()) {
if (ch.math) continue;
if (ch.signal < on.size()) on[ch.signal] = true;
}
for (std::size_t si = 0; si < model.signals().size(); ++si) {
if (model.signals()[si].math || !on[si]) continue;
st.enabled.push_back(model.signals()[si].name);
}
// Only the signals the user actually re-coloured: the rest take the positional palette, and
// writing those out would freeze a colour that is meant to follow the signal list.
for (const auto& [name, rgb] : app.rows->signal_colors()) {
st.colors.push_back({name, rgb});
}
// The app's own lists are the pane's source of truth for both of these.
st.math = app.math;
st.measures = app.measures;
st.measure_stats = app.measure_stats_on;
st.side_tab = app.side_tab;
// The trigger, its settings *and* whether it was on. The channel goes by name, like every identity
// in this file: every rebuild renumbers the channels.
{
const TriggerConfig& tc = app.model->trigger();
const std::vector<ChannelInfo>& channels = app.model->channels();
st.trigger.enabled = tc.enabled;
if (tc.channel >= 0 && tc.channel < static_cast<int>(channels.size())) {
st.trigger.channel = channels[static_cast<std::size_t>(tc.channel)].name;
}
st.trigger.type = trigger_type_text(tc.condition.type);
st.trigger.edge = trigger_edge_text(tc.condition.edge);
st.trigger.cmp = width_cmp_text(tc.condition.cmp);
st.trigger.mode = trigger_mode_text(tc.mode);
st.trigger.level = tc.condition.level;
st.trigger.level2 = tc.condition.level2;
st.trigger.width_s = tc.condition.width_s;
st.trigger.width2_s = tc.condition.width2_s;
st.trigger.timeout_s = tc.condition.timeout_s;
st.trigger.pretrigger_pct = tc.pretrigger_pct;
st.trigger.holdoff_s = tc.holdoff_s;
st.trigger.noise_band = tc.condition.noise_band;
st.trigger.hf_fc = tc.condition.hf_fc;
st.trigger.persistence = tc.persist_depth;
st.trigger.density = tc.density;
st.trigger.density_decay = tc.density_decay == PersistDecay::Time ? "time" : "acquisitions";
st.trigger.density_tau_s = tc.density_tau_s;
st.trigger.density_factor = tc.density_factor;
st.trigger.level_given = true; // written means given, so a load keeps it
}
// The connection is the session's too: the file's name carries only host and port.
st.multicast = app.source_multicast;
st.interface_addr = app.source_interface;
// The configuration's own identity and the startup settings it carries. The buffer size is a
// *startup* parameter -- the rings are allocated before any stream is known -- so a configuration
// is the only place it can be remembered per source, and the chooser applies it on open.
st.comment = app.config_comment;
st.buffer_mb = app.model->config().buffer_mb;
st.relative_time = app.model->config().relative_time;
st.capture_packets = app.model->config().capture_packets;
return st;
}
void on_config_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
ConfigDialog* d = static_cast<ConfigDialog*>(user);
if (row == nullptr) return;
const char* name = static_cast<const char*>(g_object_get_data(G_OBJECT(row), "config-name"));
if (name == nullptr) return;
App& app = *d->app;
// The session on screen is saved first: switching configurations must not be the one gesture
// that loses the arrangement it is switching *from*.
save_layout_now(app);
LayoutState st;
const std::string path = configuration_path(app.source_host, app.source_port, name);
if (!load_layout(path, st)) {
set_notice(app, std::string("could not read the configuration \"") + name + "\"", 4);
return;
}
app.config_name = name;
app.config_comment = st.comment;
set_active_configuration(app.source_host, app.source_port, name);
apply_layout(app, st);
// A buffer size, a clock and a capture ring are decided before the first socket: saying so beats
// a configuration that appears to half-apply.
const Config& cfg = app.model->config();
std::string later;
if (st.buffer_mb > 0.0 && st.buffer_mb != cfg.buffer_mb) {
later += "buffer " + std::to_string(static_cast<int>(st.buffer_mb)) + " MB";
}
if (st.relative_time != cfg.relative_time) {
later += later.empty() ? "" : ", ";
later += st.relative_time ? "relative time" : "stream time";
}
if (st.capture_packets != cfg.capture_packets) {
later += later.empty() ? "" : ", ";
later += "packet capture";
}
set_notice(app, "configuration \"" + std::string(name) + "\" loaded" +
(later.empty() ? "" : " (" + later + " takes effect on the next start)"),
4);
config_dialog_refresh(*d);
}
void on_config_forget_clicked(GtkButton* button, gpointer user) {
ConfigDialog* d = static_cast<ConfigDialog*>(user);
const char* name = static_cast<const char*>(g_object_get_data(G_OBJECT(button), "config-name"));
if (name == nullptr) return;
App& app = *d->app;
if (app.config_name == name) {
// The active one is being deleted: the next save picks whatever is left, which is what
// `active_configuration()` answers once the name is gone.
app.config_name.clear();
app.config_comment.clear();
}
forget_configuration(app.source_host, app.source_port, name);
config_dialog_refresh(*d);
}
void on_config_save_as(GtkButton*, gpointer user) {
ConfigDialog* d = static_cast<ConfigDialog*>(user);
const std::string name = gtk_editable_get_text(GTK_EDITABLE(d->name));
const std::string comment = gtk_editable_get_text(GTK_EDITABLE(d->comment));
if (name.empty()) {
set_notice(*d->app, "a configuration needs a name", 4);
return;
}
if (!save_configuration_as(*d->app, name, comment)) {
set_notice(*d->app, "could not write the configuration", 4);
return;
}
gtk_editable_set_text(GTK_EDITABLE(d->name), "");
gtk_editable_set_text(GTK_EDITABLE(d->comment), "");
config_dialog_refresh(*d);
}
void config_dialog_refresh(ConfigDialog& d) {
while (GtkWidget* child = gtk_widget_get_first_child(d.list)) {
gtk_list_box_remove(GTK_LIST_BOX(d.list), child);
}
const std::vector<std::string> names =
configuration_names(d.app->source_host, d.app->source_port);
if (names.empty()) {
GtkWidget* empty = gtk_label_new("No configurations yet -- save this session below.");
gtk_widget_add_css_class(empty, "dim-label");
gtk_widget_set_margin_top(empty, 12);
gtk_widget_set_margin_bottom(empty, 12);
gtk_list_box_append(GTK_LIST_BOX(d.list), empty);
return;
}
for (const std::string& name : names) {
const std::string path = configuration_path(d.app->source_host, d.app->source_port, name);
LayoutState st;
if (!load_layout(path, st)) continue;
GtkWidget* row = gtk_list_box_row_new();
g_object_set_data_full(G_OBJECT(row), "config-name", g_strdup(name.c_str()), g_free);
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
gtk_widget_set_margin_start(line, 10);
gtk_widget_set_margin_end(line, 4);
gtk_widget_set_margin_top(line, 5);
gtk_widget_set_margin_bottom(line, 5);
GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_widget_set_hexpand(text, TRUE);
std::string title = name;
if (name == d.app->config_name) title += " (active)";
GtkWidget* title_label = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(title_label), ("<b>" + title + "</b>").c_str());
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(text), title_label);
if (!st.comment.empty()) {
GtkWidget* note = gtk_label_new(st.comment.c_str());
gtk_widget_add_css_class(note, "dim-label");
gtk_widget_set_halign(note, GTK_ALIGN_START);
gtk_label_set_ellipsize(GTK_LABEL(note), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(text), note);
}
int signals = 0;
for (const LayoutState::Row& r : st.rows) {
for (const std::string& sig : r.signals) {
if (!sig.empty()) ++signals;
}
}
std::string sub = std::to_string(st.rows.size()) + " plots, " + std::to_string(signals) +
" signals";
if (st.buffer_mb > 0.0) {
sub += ", buffer " + std::to_string(static_cast<int>(st.buffer_mb)) + " MB";
}
if (st.relative_time) sub += ", relative time";
GtkWidget* counts = gtk_label_new(sub.c_str());
gtk_widget_add_css_class(counts, "dim-label");
gtk_widget_set_halign(counts, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(text), counts);
gtk_box_append(GTK_BOX(line), text);
GtkWidget* forget = gtk_button_new_from_icon_name("window-close-symbolic");
gtk_widget_set_tooltip_text(forget, "Delete this configuration");
gtk_widget_add_css_class(forget, "flat");
gtk_widget_set_valign(forget, GTK_ALIGN_CENTER);
g_object_set_data_full(G_OBJECT(forget), "config-name", g_strdup(name.c_str()), g_free);
g_signal_connect(forget, "clicked", G_CALLBACK(on_config_forget_clicked), &d);
gtk_box_append(GTK_BOX(line), forget);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
gtk_list_box_append(GTK_LIST_BOX(d.list), row);
}
}
/// Writes the layout file (a debounced schedule lands here once). Errors go to
/// stderr; success is silent -- a save per click must not narrate.
void save_layout_now(App& app) {
if (app.source_port == 0) return;
// Nothing is known about this source yet -- no CONFIG ever arrived -- so every slot name is
// empty and this would overwrite a configured session with `signals = ["", ...]`. The startup
// chooser filters such a file out of its list, so a source whose streamer was down during one
// run would silently vanish from the only place it can be picked. Keeping the old file loses
// nothing: this run configured nothing.
if (app.model->signals().empty()) return;
// A file view is not the source's session, and the same argument applies: this view configured
// nothing about the stream. A replay leaves the rows where the user put them, so its save would
// merely be pointless -- but a *group* rebinds every slot to its own renamed channels, and a save
// taken while one was up wrote `signals = ["square (A)", ...]` into the file. The next load then
// matched no live signal, enabled nothing, and the live view came back with no channels at all,
// on the window the poisoned file carried. Skipping costs nothing: the live session's own file is
// already on disk, and `go_live_again()` puts the rest back.
if (app.replaying) return;
if (app.config_name.empty()) app.config_name = active_configuration(app.source_host,
app.source_port);
const std::string path =
configuration_path(app.source_host, app.source_port, app.config_name);
if (!save_layout(path, collect_layout(app))) {
std::fprintf(stderr, "[udpscope] failed to write the layout file\n");
std::fflush(stderr);
return;
}
// Which configuration the source is on is part of remembering it: the next run opens this one.
set_active_configuration(app.source_host, app.source_port, app.config_name);
}
/// Writes the current session under a *new* name, with a note, and makes it the source's active
/// configuration. The one entry point for both the dialog's button and the probe: a probe that
/// rolled its own would test a different path than the user's.
bool save_configuration_as(App& app, const std::string& name, const std::string& comment) {
if (name.empty() || app.source_port == 0 || app.model->signals().empty()) return false;
app.config_name = name;
app.config_comment = comment;
const std::string path = configuration_path(app.source_host, app.source_port, name);
if (!save_layout(path, collect_layout(app))) {
std::fprintf(stderr, "[udpscope] failed to write the configuration file\n");
std::fflush(stderr);
return false;
}
set_active_configuration(app.source_host, app.source_port, name);
return true;
}
/// One-shot debounce: a burst of changes (a drop, a row insert, a calibration
/// commit in the same second) writes once, 400 ms after the last one.
gboolean layout_save_tick(gpointer user) {
App* app = static_cast<App*>(user);
app->layout_save_source = 0;
save_layout_now(*app);
return G_SOURCE_REMOVE;
}
void schedule_layout_save(App& app) {
if (app.source_port == 0) return;
if (app.layout_save_source != 0) return;
app.layout_save_source = g_timeout_add(400, layout_save_tick, &app);
}
/// Applies a loaded layout: rows, slot assignments, calibration, window. Names
/// that no longer exist in the stream are skipped rather than freeing their slot
/// (a stream that renamed a signal must not blank the user's layout).
/// The session's trigger into the model and the panel, called from `apply_layout()`. The *channel* it
/// names is resolved where the channels exist -- `rebuild_trigger_signals()` picks up
/// `App::pending_trigger_channel` -- because at this point the stream may not have described its
/// signals yet, and a name that resolves to nothing would silently move the trigger to channel 0.
void apply_trigger_layout(App& app, const LayoutState::Trigger& t) {
{
std::lock_guard<std::mutex> lock(app.model->mutex());
TriggerConfig& tc = app.model->trigger();
tc.condition.type = trigger_type_from_text(t.type);
tc.condition.edge = trigger_edge_from_text(t.edge);
tc.condition.cmp = width_cmp_from_text(t.cmp);
tc.mode = trigger_mode_from_text(t.mode);
tc.condition.level = t.level;
tc.condition.level2 = t.level2;
tc.condition.width_s = t.width_s;
tc.condition.width2_s = t.width2_s;
tc.condition.timeout_s = t.timeout_s;
tc.condition.noise_band = t.noise_band;
tc.condition.hf_fc = t.hf_fc;
tc.pretrigger_pct = t.pretrigger_pct;
tc.holdoff_s = t.holdoff_s;
// The phosphor density: a plain field write, unlike the multiple-event depth below -- there is
// nothing to prune, because the image is not retained data. Its own clamp is the plotter's
// (`full_scale`/`gamma` are the plot's), and the two numbers a file may carry anywhere are
// already inside the floats the plot takes.
tc.density = t.density;
tc.density_decay = t.density_decay == "time" ? PersistDecay::Time : PersistDecay::Acquisitions;
tc.density_tau_s = t.density_tau_s;
tc.density_factor = t.density_factor;
}
// The persistence depth is not a field write like the rest: it *prunes* the retained
// acquisitions to what the file asks for, so it goes through the one setter (which takes the lock
// itself and clamps to the cap).
app.model->set_persist_depth(t.persistence);
// A file that carried a level keeps it: the panel's own default follows the channel's range, and
// `trigger_level_touched` is what says the user has a level of their own.
if (t.level_given) app.trigger_level_touched = true;
app.pending_trigger_channel = t.channel;
{
// If the stream has already described its channels, resolve the name *now*: the signals rebuild that
// would otherwise do it has already been and gone, so a pending name would leave the trigger on
// channel 0 -- which is a trigger on whichever signal happens to be first.
const std::vector<ChannelInfo>& channels = app.model->channels();
for (std::size_t c = 0; c < channels.size(); ++c) {
if (channels[c].name != app.pending_trigger_channel) continue;
std::lock_guard<std::mutex> lock(app.model->mutex());
app.model->trigger().channel = static_cast<int>(c);
app.pending_trigger_channel.clear();
break;
}
}
write_trigger_widgets(app);
// The state is applied by *pressing the same control a user would*: the handler is what arms, tells
// the events button, greys the body and syncs the pane's own switch. Deliberately outside the
// `updating_panels` window `apply_layout()` uses for the panels, because a toggle set inside it
// would emit `toggled` into a handler that returns at once and nothing would happen.
if (app.trigger_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), t.enabled);
}
sync_panel_switches(app);
}
void apply_layout(App& app, const LayoutState& st) {
ScopeModel& model = *app.model;
const auto& signals = model.signals();
// The window before anything structural. Nothing is enabled yet, so there are no channels
// to plan for and this only *records* the span -- but it is the span every plan below is
// then made with, so the `apply_time_window()` at the end of this function finds the
// decimation already right and re-plans nothing. Stated last, as it used to be, it moved
// the stride and bought a second full allocation of the buffer after the data was already
// flowing into the first.
if (st.window_s > 0.0) model.set_target_span(st.window_s);
// The math graph before the enables, because a math channel's *name* is only a signal once
// its graph is installed -- a row that holds one would otherwise be skipped as a name the
// stream no longer has -- and because installing it while nothing is enabled is the one
// ordering in which it does not pay for a buffer of its own. `signals` is a reference to
// the model's own vector, so the synthetic entries this appends are visible below.
app.math = st.math;
apply_math_channels(app);
// What the session had on, collected and enabled **once**, at the end of this function.
// Each enable re-plans the partition, and a re-plan allocates -- and so touches -- the whole
// of `--buffer-mb`: seven signals one at a time is seven allocations before a single sample
// is kept, which is the ten seconds of blank window a large buffer used to start with. The
// math channels are not collected here: their enable state is the session's `math` and
// `set_math_channels()` has already restored it, so listing them again could turn on one
// the user saved off.
std::vector<std::size_t> wanted;
wanted.reserve(st.enabled.size());
for (const std::string& name : st.enabled) {
for (std::size_t si = 0; si < signals.size(); ++si) {
if (signals[si].name != name || signals[si].math) continue;
wanted.push_back(si);
break;
}
}
app.measures = st.measures;
app.measure_stats_on = st.measure_stats;
app.updating_panels = true;
select_side_tab(app, st.side_tab.c_str());
if (app.measures_stats_check != nullptr) {
gtk_check_button_set_active(GTK_CHECK_BUTTON(app.measures_stats_check),
app.measure_stats_on);
}
// The toggled handler is guarded by updating_panels, so the Reset button's sensitivity
// and the stats-label visibility are set directly from the loaded state here; the labels'
// visibility is also set per-row inside rebuild_measures_pane via measures_row().
if (app.measures_stats_reset != nullptr)
gtk_widget_set_sensitive(app.measures_stats_reset, app.measure_stats_on);
app.updating_panels = false;
rebuild_measures_pane(app);
int rows = static_cast<int>(st.rows.size());
if (rows > RowSet::kMaxRows) rows = RowSet::kMaxRows;
if (rows > 0) app.rows->set_row_count(rows);
// The rows' shares of the height come from the same file as their signals: a sizing a user dragged is
// an arrangement, and the layout is where an arrangement lives. Absent from an older file, the weights
// are all 1 -- equal rows -- which is what such a file was saved with.
{
std::vector<float> weights;
weights.reserve(st.rows.size());
for (const LayoutState::Row& wr : st.rows) weights.push_back(static_cast<float>(wr.weight));
app.rows->set_row_weights(std::move(weights));
}
for (int i = 0; i < rows; ++i) {
int slot = 0;
for (const std::string& name : st.rows[static_cast<std::size_t>(i)].signals) {
if (slot >= RowSet::kSlotsPerRow) break;
if (name.empty()) continue;
for (std::size_t si = 0; si < signals.size(); ++si) {
if (signals[si].name != name) continue;
// A signal the model will never build a channel for must not take a slot. The
// stream's time signal is skipped by `build_channels_locked()` however often it is
// enabled, so such a slot holds a name for ever and data never -- and because
// `collect_layout()` writes the names straight back, one that got in stayed in and
// was re-saved every session. On the plot it drew nothing; in the cursors pane's
// `mix` mode it drew a line that could only read `--`.
if (ScopeModel::is_time_signal(signals, si)) break;
// Enabling is what starts acquisition (a loaded plot nobody acquires would
// come back blank), and it is what a drop does for the signal it places.
app.rows->assign(i, slot, name, name, signal_color_index(signals, si));
wanted.push_back(si);
++slot;
break;
}
}
}
// The one structural change of a restored session: everything the file had on, and
// everything its rows placed, in a single re-plan. Enabling is what starts acquisition (a
// loaded plot nobody acquires would come back blank), and it is what a drop does for the
// signal it places -- so a row holding a signal the `enabled` list did not name is enabled
// here, by having been collected above.
model.set_signals_enabled(wanted, true);
for (const auto& cal : st.calibration) {
for (std::size_t si = 0; si < signals.size(); ++si) {
if (signals[si].name != cal.name) continue;
model.set_calibration(si, cal.gain, cal.offset);
break;
}
}
// Skipped for a name the stream no longer carries, like the calibration above: an override the
// signal list cannot show is one `collect_layout()` would write back every session for ever.
for (const auto& color : st.colors) {
for (std::size_t si = 0; si < signals.size(); ++si) {
if (signals[si].name != color.name) continue;
app.rows->set_signal_color(color.name, color.rgb);
break;
}
}
if (st.window_s > 0.0) apply_time_window(app, st.window_s);
// set_row_count() rebuilt the figure (its row count is fixed at construction):
// re-point the area, as every other row-count change must, and refresh the
// signal panel's chips so they name the traces the file brought back.
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
app.relayout_pending = true;
rebuild_signal_panels(app);
// The trigger last: its channel names an *acquired* channel, so it is resolved once the enables
// above have built the channel list.
apply_trigger_layout(app, st.trigger);
// A session that loaded is a user who has been here before, and that is what the lock means: the two
// panes come up showing what they are set to rather than showing every control. A first run -- no
// file for this source -- never reaches this function and stays in configuration mode, which is what
// a first run is for.
app.trigger_locked = true;
app.signals_locked = true;
apply_pane_lock(app);
}
void on_layout_row_remove(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const int row = row_key(GTK_WIDGET(button));
if (!app->rows->remove_row(row)) {
app->notice = "a figure needs at least one row";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
return;
}
// `remove_row()` has already built the new figure (a `Figure`'s row count is fixed at
// construction), so the area has to be re-pointed here rather than through
// `relayout_areas()`, which only acts on a *dirty* row.
if (app->area != nullptr) app->area->set_figure(&app->rows->figure());
rebuild_layout_editor(*app);
// The signal list's chips name the trace a signal is drawn as, and a removed row took
// its traces with it.
rebuild_signal_panels(*app);
schedule_layout_save(*app);
}
void on_layout_row_insert(GtkButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const int at = row_key(GTK_WIDGET(button));
if (!app->rows->insert_row(at)) {
app->notice = "at most " + std::to_string(RowSet::kMaxRows) + " rows";
app->notice_until_us = g_get_monotonic_time() + 3 * G_TIME_SPAN_SECOND;
return;
}
if (app->area != nullptr) app->area->set_figure(&app->rows->figure());
rebuild_layout_editor(*app);
rebuild_signal_panels(*app);
schedule_layout_save(*app);
}
/// The `Done` button: the toolbar toggle is the one piece of state that says whether the
/// editor is up, so this turns *that* off and the existing `toggled` handler does the rest
/// (including taking this button down).
void on_layout_editor_done(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->layout_toggle), false);
}
void on_layout_editor_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
app->layout_editing = gtk_toggle_button_get_active(button);
rebuild_layout_editor(*app);
}
/// The editor button of one kind at one index, or null. The probe clicks the real
/// widgets rather than calling the handlers, so what it drives is what a user reaches.
GtkWidget* layout_button(App& a, bool insert, int index) {
for (GtkWidget* b : a.layout_buttons) {
const bool is_insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr;
if (is_insert == insert && row_key(b) == index) return b;
}
return nullptr;
}
/// Clicks one editor button. `::clicked` directly and not `gtk_widget_activate()`: a
/// `GtkButton` activated that way holds the press for `ACTIVATE_TIMEOUT` (250 ms) and only
/// then emits `::clicked`, so every step of the probe read the *previous* step's result --
/// and the button whose timeout was pending is destroyed by the rebuild the step before it
/// caused, which dropped the click entirely.
void layout_click(GtkWidget* b) {
if (b != nullptr) g_signal_emit_by_name(b, "clicked");
}
} // namespace ui_impl
} // namespace udpscope