Files
udpscope/src/ui_layout.cpp
T
Martino Ferrari 0e256764d5 udpscope: an oscilloscope view for a UDPStreamer source
GTK4 and libadwaita. It receives one UDP stream through the vendored
udpsclient, buffers it in a large ring, and draws it with the vendored
plotter: a trigger with a pre/post event window, a pause that freezes the
window, cursors, measurements, math channels in a node editor, an event cache,
named configurations, preferences, and an export of the visible window to
.npy/.csv. Waveform files can be opened and analysed in place.

The interface is two threads -- a producer that owns the socket and the GTK
main thread that owns every widget and the GL context -- sharing one model
under one mutex, with samples crossing through lock-free rings.

deps/plotter is a submodule pinned to the commit this tree builds against;
deps/udpsclient is vendored in place. docs/claude/ documents the rules that
cost a defect to learn, and SPECS.md is the requirement document.
2026-10-11 15:21:40 +02:00

447 lines
19 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;
// 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;
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).
void apply_layout(App& app, const LayoutState& st) {
ScopeModel& model = *app.model;
const auto& signals = model.signals();
// The session's own state first: enabling is what starts acquisition, and 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.
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;
model.set_signal_enabled(si, true);
break;
}
}
app.math = st.math;
apply_math_channels(app);
app.measures = st.measures;
app.measure_stats_on = st.measure_stats;
app.updating_panels = true;
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));
model.set_signal_enabled(si, true);
++slot;
break;
}
}
}
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);
}
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