Files
udpscope/src/ui_layout.cpp
T
Martino Ferrari 121abbefae feat: locked panes for a returning user, and the trigger is persisted
Two panes, two modes. **Locked** is what a returning user gets: the trigger pane shows one line saying
what it is set to, the signal list shows only the signals that are on, and neither offers a control that
changes the configuration — each heading has an `[edit]` button (the Adwaita `emblem-system-symbolic`,
swapping to `emblem-ok-symbolic` in configuration mode) that flips its pane. A **first run** — no session
file for that source — never reaches `apply_layout()` and comes up in configuration mode, because
configuring the host is what a first run is for. The mode is a mode and not a setting, so it is
deliberately not persisted.

The locked signal list drops the rows of every signal that is off, which is the whole of "show active
signals": the list already filters on nothing, so the lock is one `continue`. With the rows go the acquire
tick, the overflow menu (Information, Calibrate…, Delete, rename) and the `MATH` divider's `+` — and the
colour chip stops being clickable, because choosing a colour is configuration too. Dragging a signal onto
a plot stays: that is which view shows what, not what the scope is set up to do.

**The trigger is persisted, settings and state both** — the settings *even while it is off*, which is the
point: a trigger configured once and switched off is still the trigger you come back to. `LayoutState`
gains fifteen flat `trigger_*` keys; the channel is stored by *name* (every rebuild renumbers the
channels, the model's own rule) and the two enumerations as **the words the panel's dropdowns show**,
because an index ties the file to a declaration order — the lesson `math_op_at()` already carries. The
words are shared with the panel now (`trigger_type_text()` and friends build the dropdown arrays), so the
two cannot drift.

Two ordering traps, both found by running it:

- **The channel name resolves twice over.** `rebuild_trigger_signals()` picks up
  `App::pending_trigger_channel` when the stream arrives *after* the load, and `apply_trigger_layout()`
  resolves it itself when the channels are already there — without the second path the name stayed
  pending past the one rebuild that would have used it, and the trigger silently sat on channel 0
  (`summary='pulse width · sine …'` where the session said `square`).
- **The trigger's state is applied by pressing the control, not by setting a flag**: the toggle is set
  *outside* the `updating_panels` window `apply_layout()` uses, because a toggle set inside it emits
  `toggled` into a handler that returns at once and nothing would happen — no arming, no events button,
  no sensitivity.

Verified, in both modes and both directions:

    panes: trigger=locked signals=locked body_visible=0 summary='window · square · 0.3…0.6 · 20 % pre'
    panes: trigger=config signals=config body_visible=1 summary=''
    signals panel: labels=2 list=1 rows=7 signals=2 math=4 divider=1

The locked trigger pane is 42 px tall against 580 unlocked. A hand-written session round-trips: every
value it named came back in the panel's own controls, `trigger=1/1` (the state arrived and the pane's
switch followed it), and the file the app wrote back carried all fifteen keys with the untouched ones at
their defaults. `[side-tab-probe] … advanced=4` on its documented recipe; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon.

Docs: the `panes:` reading with what each field means, the corrected `labels=` note (the panel's frame is
counted above the list now, because the heading sits in a heading row), the invariants for both the
persistence and the lock, the README's deviation note, and the removal of its claim that the trigger has
never been persisted — it is now, and the locked view is built from exactly that.
2026-10-14 14:31:46 +02:00

568 lines
26 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) {
row.signals.push_back(r.signal_name[static_cast<std::size_t>(s)]);
}
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.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;
}
// 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