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.
568 lines
26 KiB
C++
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
|