// 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 #include 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(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& weights = app.rows->row_weights(); if (static_cast(i) < weights.size()) row.weight = weights[static_cast(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 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& channels = app.model->channels(); st.trigger.enabled = tc.enabled; if (tc.channel >= 0 && tc.channel < static_cast(channels.size())) { st.trigger.channel = channels[static_cast(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(user); if (row == nullptr) return; const char* name = static_cast(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(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(user); const char* name = static_cast(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(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 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), ("" + title + "").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(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(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 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& 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 lock(app.model->mutex()); app.model->trigger().channel = static_cast(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 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(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 weights; weights.reserve(st.rows.size()); for (const LayoutState::Row& wr : st.rows) weights.push_back(static_cast(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(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(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(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(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(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