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.
This commit is contained in:
Martino Ferrari committed 2026-10-11 15:21:40 +02:00
1 parent 8f54841c2c
commit 0e256764d5
41 files changed
+2081 -154

No files matched your search

+199 -15
View File
@@ -613,7 +613,21 @@ void sync_layout_editor(App& app) {
for (GtkWidget* b : app.layout_buttons) {
const int idx = row_key(b);
const bool insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr;
if (insert) {
const bool resize = g_object_get_data(G_OBJECT(b), kLayoutResizeKey) != nullptr;
if (resize) {
// *On* the boundary, beside the `+` -- the same edge, one handle's width to its right. Below it sat
// inside the lower row, which read as a handle for that row: a boundary handle that looks like it
// belongs to the plot under it is a handle whose drag changes the wrong plot, whatever it does.
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
: (static_cast<double>(fl.rows.back().y) +
static_cast<double>(fl.rows.back().h)) / scale;
const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx);
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
const double width = static_cast<double>(gtk_widget_get_width(app.area_widget));
const double left = width * 0.5 + kLayoutButtonPx * 0.5 + 6.0;
gtk_widget_set_margin_start(
b, static_cast<int>(std::clamp(left, 0.0, std::max(0.0, width - kLayoutButtonPx)) + 0.5));
} else if (insert) {
// Centred on the boundary: the top edge of row `idx`, or the bottom edge of the
// last row for the one past the end.
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
@@ -637,6 +651,52 @@ void sync_layout_editor(App& app) {
}
}
/// A row may not be dragged below this, in logical pixels: a row of no height is a row a user cannot
/// grab again, and the plotter keeps a floor of its own -- this is the one the *drag* respects.
constexpr double kMinRowPx = 48.0;
void on_layout_drag_begin(GtkGestureDrag* gesture, double, double, gpointer user) {
App& app = *static_cast<App*>(user);
app.layout_drag_row =
static_cast<int>(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(gesture), "udpscope-layout-drag")));
// A drag is *relative*: the weights it started from are kept, because reading them back while dragging
// would compound -- each update would build on the last frame's instead of on the grab.
app.layout_drag_start = app.rows->row_weights();
app.layout_drag_start.resize(static_cast<std::size_t>(app.rows->row_count()), 1.0f);
}
void on_layout_drag_update(GtkGestureDrag*, double, double offset_y, gpointer user) {
App& app = *static_cast<App*>(user);
const int at = app.layout_drag_row; // the boundary between rows `at - 1` and `at`
const int rows = app.rows->row_count();
if (at <= 0 || at >= rows) return; // the figure's own edges are not boundaries
if (app.layout_drag_start.size() != static_cast<std::size_t>(rows)) return;
const plot::FigureLayout& fl = app.rows->figure().layout();
if (static_cast<int>(fl.rows.size()) != rows) return;
const double scale =
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
const double up = static_cast<double>(fl.rows[static_cast<std::size_t>(at - 1)].h) / scale;
const double lo = static_cast<double>(fl.rows[static_cast<std::size_t>(at)].h) / scale;
const double pair = up + lo;
if (!(pair > kMinRowPx * 2.0)) return;
// Where the boundary is asked to go, floored on both sides: the *pixels* say where, and the pair's
// weights are the shares that put it there.
const double want_up = std::clamp(up + offset_y / scale, kMinRowPx, pair - kMinRowPx);
std::vector<float> weights = app.layout_drag_start;
const float sum = weights[static_cast<std::size_t>(at - 1)] + weights[static_cast<std::size_t>(at)];
weights[static_cast<std::size_t>(at - 1)] = sum * static_cast<float>(want_up / pair);
weights[static_cast<std::size_t>(at)] = sum - weights[static_cast<std::size_t>(at - 1)];
app.rows->set_row_weights(std::move(weights));
}
void on_layout_drag_end(GtkGestureDrag*, double, double, gpointer user) {
App& app = *static_cast<App*>(user);
app.layout_drag_row = -1;
app.layout_drag_start.clear();
// A dragged sizing is an arrangement, so it is saved like one.
schedule_layout_save(app);
}
/// Builds one `×` per row and one `+` per boundary, or takes them all off again when the
/// editor is switched off.
void rebuild_layout_editor(App& app) {
@@ -676,6 +736,39 @@ void rebuild_layout_editor(App& app) {
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
app.layout_buttons.push_back(b);
}
// ... and one arrow per boundary whose *only* job is resizing the two rows it sits between. A widget
// of its own rather than a second gesture on the `+`, so each handle does one thing -- and a handle
// that cannot be clicked cannot be clicked by accident. The figure's own edges are not boundaries
// (there is no pair of rows to share between them), so the first and last are inert.
for (int b_i = 0; b_i <= rows; ++b_i) {
GtkWidget* b = gtk_button_new_from_icon_name("udpscope-resize-rows-symbolic");
gtk_widget_set_tooltip_text(b, b_i == 0 ? "drag to resize the first row"
: b_i == rows ? "drag to resize the last row"
: "drag to resize the two rows here");
gtk_widget_add_css_class(b, "circular");
gtk_widget_add_css_class(b, "osd");
gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
gtk_widget_set_halign(b, GTK_ALIGN_CENTER);
gtk_widget_set_valign(b, GTK_ALIGN_START);
// Hidden, not disabled, where it has no pair to share between: the figure's own edges. A greyed
// handle is a question ("why can I not drag this?"), and the answer is that there is nothing there.
gtk_widget_set_visible(b, b_i > 0 && b_i < rows);
gtk_widget_set_halign(b, GTK_ALIGN_START);
// The *row key* is what `sync_layout_editor()` places by -- without it every arrow was placed at
// index 0, the figure's top edge, whatever boundary it belonged to.
set_row_key(b, b_i);
g_object_set_data(G_OBJECT(b), kLayoutResizeKey, GSIZE_TO_POINTER(b_i));
GtkGesture* drag = gtk_gesture_drag_new();
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), 1);
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(drag), GTK_PHASE_CAPTURE);
g_object_set_data(G_OBJECT(drag), "udpscope-layout-drag", GSIZE_TO_POINTER(b_i));
g_signal_connect(drag, "drag-begin", G_CALLBACK(on_layout_drag_begin), &app);
g_signal_connect(drag, "drag-update", G_CALLBACK(on_layout_drag_update), &app);
g_signal_connect(drag, "drag-end", G_CALLBACK(on_layout_drag_end), &app);
gtk_widget_add_controller(b, GTK_EVENT_CONTROLLER(drag));
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
app.layout_buttons.push_back(b);
}
// The editor's way out, in the area's top-right corner: the toolbar's toggle is in the
// overflow menu, which is a long way from the plot you are editing. Aligned by margins
@@ -3149,7 +3242,13 @@ gboolean tick(gpointer user) {
// bound to nothing and the snapshot carries its own `x_offset`, so the epoch is 0 and
// `ScopeModel::stream_at_x0()` is what crosses between the clocks.
double want_epoch = 0.0;
if (display == DisplayMode::Capturing) {
if (!app->waveform_name.empty()) {
// A file *is* an event: an export's stamps are already trigger-relative, so the trigger instant
// is x = 0 and the epoch must not move it. The live rule below ("the newest sample sits at 0")
// would put the trigger at the right edge and push the whole pre-trigger half off the screen --
// which is what "I only see 0 to 0.2 of a -0.8 to 0.2 recording" was.
want_epoch = 0.0;
} else if (display == DisplayMode::Capturing) {
want_epoch = -cap_edge;
} else if (model.display_snapshot() == nullptr) {
// `live_edge()` reports in the epoch currently in force, so subtracting it is minus the
@@ -3166,13 +3265,53 @@ gboolean tick(gpointer user) {
// Outside the lock: it reads and writes the two plots' own windows, not the model.
sync_compare_axes(*app);
sync_events_divider(*app);
// The comparison's plot needs the same re-point the primary gets from `relayout_areas()`: a
// `RowSet` rebuilds its *figure* when a row's trace set changes, and the area has to be handed the
// new one. Nothing did that for the second plot, so it kept drawing the figure built when its slots
// were still empty -- every trace in the palette's first colour, which is blue. Idempotent: the
// area is handed the figure it already has on every other frame.
// A file on screen is an event, and gets what a held capture gets: the axis pinned to the event's own
// `[-pre, +post]` and the trigger instant marked at x = 0. That *is* the constraint -- the time base is
// the event's, so no gesture can take the view outside it -- and the window control reads the event's
// length. The extent comes from the ring itself, so nothing has to remember it: with the epoch at 0
// the stamps are the display x.
if (!app->waveform_name.empty() && !app->capturing && app->rows != nullptr) {
double lo = 0.0;
double hi = 0.0;
{
const auto& t = app->model->live().time();
const std::uint64_t n = t.size();
if (n > 0) {
lo = t[0];
hi = t[n - 1];
}
}
if (hi > lo) {
// State the pin **once**, then only its numbers. `pin_event_window()` writes the group's range as
// well as the pin, and doing that every frame undid the user's box zoom before the clamp below
// could adopt it -- the same defect the wheel had in single trigger mode, where re-stating the pin
// made it a no-op. The held-event branch above has this shape for this reason: `set_data_pin()`
// for the numbers, `pin_event_window()` when the view first becomes an event. A re-open into the
// same row set with a different extent *is* a new event and re-states it.
const auto pin_view = [lo, hi](RowSet& rows) {
if (rows.pinned_window_is(-lo, hi)) {
rows.set_data_pin(-lo, hi);
} else {
rows.pin_event_window(-lo, hi);
}
rows.set_trigger_marker(0.0, true);
};
pin_view(*app->rows);
if (app->rows_b != nullptr) pin_view(*app->rows_b);
sync_window_display(*app, hi - lo);
}
}
// The comparison's plot needs the same re-point the primary gets from `relayout_areas()`: a `RowSet`
// rebuilds its *figure* when a row's trace set changes, and the area has to be handed the new one.
// Only when it *did* change, though -- `set_figure()` creates **a controller per row**, so handing it
// the same figure every frame threw away the second plot's interaction state sixty times a second:
// its box-zoom and its crosshair never took, while the primary's (re-pointed only on a dirty row) did.
if (app->comparing && app->area_b != nullptr && app->rows_b != nullptr) {
app->area_b->set_figure(&app->rows_b->figure());
plot::Figure* want = &app->rows_b->figure();
if (app->area_b_figure != want) {
app->area_b->set_figure(want);
app->area_b_figure = want;
}
}
// The gesture probe's verdicts, one frame after the ask and after this frame's clamp: a
@@ -3258,10 +3397,18 @@ gboolean tick(gpointer user) {
double hi = 0.0;
if (app->rows->event_data_window(lo, hi)) app->rows->set_data_pin(lo, hi);
app->rows->hold_pinned_window();
} else if (display == DisplayMode::Paused || app->capturing) {
// Pinned: the event (or the frozen pause), not a navigation surface. `clamp_to_data()` is
// the live-mode half of the same rule -- a live view must stay on the data -- and the two
// are complements, which is why this is one branch rather than two conditions.
} else if (display == DisplayMode::Paused || app->capturing || app->rows->window_pinned()) {
// Pinned: the event, the frozen pause *or an opened file*, not a navigation surface.
// `clamp_to_data()` is the live-mode half of the same rule -- a live view must stay on the data
// -- and the two are complements, which is why this is one branch rather than two conditions.
//
// A file belongs here and was the case that was missing. It pins its axis every frame (see the
// `waveform_name` block above), but a pinned axis does *not* mean this branch: only
// `hold_pinned_window()` adopts a gesture from the row's own axis, because a member's
// `set_x_range` is deliberately not adopted into a group that is not following. Falling through
// to `clamp_to_data()` therefore left a box zoom in a file view drawing its frame and changing
// nothing -- the gesture's range went onto the member, the group kept the pin, and the picture
// that was drawn came from the group.
app->rows->hold_pinned_window();
} else {
app->rows->clamp_to_data();
@@ -3405,7 +3552,11 @@ gboolean tick(gpointer user) {
// being captured or held. x is seconds from the event in both: the display epoch is
// minus the edge while the live ring is on a pinned axis, and the snapshot's own stamps
// are edge-relative, so the edge sits at exactly x = 0 wherever the user has zoomed.
if (!app->capturing && !(trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed))) {
// ... and it is the *only* thing that hides it. A file's own event is on screen for as long as the
// file is, so the file branch above must survive this one: it used to run first and be undone here,
// which is why an opened event had no trigger line.
if (app->waveform_name.empty() && !app->capturing &&
!(trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed))) {
app->rows->set_trigger_marker(0.0, false);
}
@@ -3588,6 +3739,15 @@ gboolean tick(gpointer user) {
const double dt_s = std::min(10.0, double(now_c - app->last_channels_meas) / 1.0e6);
next_channels = now_c + G_TIME_SPAN_SECOND;
app->last_channels_meas = now_c;
// The cache's own question, on the same cadence as the channel dump: a directory read per second,
// and the button's availability re-decided only when the answer changed.
if (app->event_store != nullptr) {
const bool cached = !app->event_store->list().empty();
if (cached != app->events_cached) {
app->events_cached = cached;
sync_events_availability(*app);
}
}
// Once a second, alongside the rate measurement: is the stream arriving at all? Nothing is
// owed in file mode -- there is no socket and no source -- so a file is never starved.
if (app->waveform_name.empty()) note_starvation(*app);
@@ -4074,6 +4234,13 @@ GtkWidget* build_toolbar(App& app) {
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), help_item);
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_preferences(*static_cast<App*>(user));
}),
&app);
gtk_box_append(GTK_BOX(menu_box), prefs_item);
gtk_box_append(GTK_BOX(menu_box), menu_separator());
GtkWidget* about_item = menu_button("About udpscope", nullptr);
gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths");
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
@@ -4169,13 +4336,18 @@ GtkWidget* build_toolbar(App& app) {
if (open) refresh_events_pane(*a);
}),
&app);
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.events_toggle);
app.pause_button = gtk_toggle_button_new();
gtk_button_set_icon_name(GTK_BUTTON(app.pause_button), "media-playback-pause-symbolic");
gtk_widget_set_tooltip_text(app.pause_button, "pause acquisition");
g_signal_connect(app.pause_button, "toggled", G_CALLBACK(on_pause_toggled), &app);
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.pause_button);
// A separator, because the events button is not another mode on the plot: what stands to its left is
// what the *view* is doing (the window, the trigger, the pause) and what follows is the cache of what
// those produced.
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), gtk_separator_new(GTK_ORIENTATION_VERTICAL));
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.events_toggle);
// Packed here, after every widget above has been created: the *pack* order is what decides the
// layout, not the creation order, and packing before the widget exists passes NULL to the header
@@ -4941,6 +5113,11 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
// Installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog`
// needs to be drawn at all. Idempotent, and `choose_source()` may already have run it.
adw_init();
// The preferences are read before anything is built: the theme decides what the window looks like,
// and the rest is in force from the first frame. A *local* until `App` exists below, which is why
// this one read happens here and the assignment after it.
const Settings startup_settings = load_settings();
apply_theme(startup_settings.theme);
// The app's own icons, before anything asks for one. They are a GResource compiled into the
// binary (see CMakeLists), and this is what makes the theme look there: an unregistered path
// is not an error, it is a missing-image placeholder.
@@ -4952,6 +5129,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
gtk_icon_theme_add_resource_path(gtk_icon_theme_get_for_display(gdk_display_get_default()),
"/org/udpscope/icons");
App app;
app.settings = startup_settings;
app.model = &model;
app.acq = &acq;
app.rows = &rows;
@@ -4969,7 +5147,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
// file-mode run has no cache either -- there are no latches in a file.
if (!app.source_host.empty() && app.source_port != 0) {
app.event_store = std::make_unique<EventStore>(app.source_host + "-" +
std::to_string(app.source_port));
std::to_string(app.source_port), app.settings.event_cache_dir);
}
refresh_events_pane(app);
@@ -5074,6 +5252,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_DEFAULTS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), defaults_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_BOXZOOM_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), boxzoom_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_PREFS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), prefs_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_CAPTURE_MS")) {
capture_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
}