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

+80 -31
View File
@@ -7,6 +7,7 @@
#include <gtk/gtk.h>
#include <limits>
#include <string>
namespace udpscope {
@@ -47,7 +48,10 @@ void sync_events_availability(App& app) {
// Available while the cache can still grow (recording on) *or* while it is being read (an event on
// screen). Not trigger mode: a stored event being examined is a file, and it has no trigger -- if the
// pane's life depended on the trigger, opening one would close the list it was opened from.
const bool available = app.event_recording || app.replaying;
// ... or whenever the cache *has* something: an event on disk outlives the switch that wrote it, and
// the button that shows it must not go with the switch. `events_cached` is refreshed once a second
// from the tick -- the store's list is a directory read, not a per-frame question.
const bool available = app.event_recording || app.replaying || app.events_cached;
gtk_widget_set_visible(app.events_toggle, available);
if (!available) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.events_toggle), FALSE);
@@ -116,7 +120,12 @@ bool record_event(App& app) {
meta.samples = table.rows();
meta.seq = app.model->event_seq();
const std::string file = app.event_store->save(table, meta);
// 0 means "keep everything", which `save()` cannot be asked for with a count: it is the largest
// number a size_t holds, and trimming then never finds anything beyond it.
const std::size_t keep = app.settings.event_cache_max <= 0
? std::numeric_limits<std::size_t>::max()
: static_cast<std::size_t>(app.settings.event_cache_max);
const std::string file = app.event_store->save(table, meta, keep);
if (file.empty()) return false;
// The event is on screen; say it was kept, and where. A notice rather than a dialog: a latch every
// 200 ms must not be a wall of windows.
@@ -171,7 +180,8 @@ bool replay_event(App& app, const EventRecord& record) {
rebuild_signal_panels(app);
// Fit on *open*, and only then: the whole event framed the moment it is opened, the user's own zooms
// left alone afterwards -- restated here and never per frame.
if (report.span_s > 0.0) apply_time_window(app, report.span_s);
// The axis is the event's own and the tick pins it there (`pin_event_window`): nothing to frame or
// home here beyond the window control's own reading, which the tick keeps in step.
// The pane is available through a replay, and this is what keeps it open.
sync_events_availability(app);
set_notice(app, "replaying " + record.stem + " (" +
@@ -376,42 +386,72 @@ bool build_event_group(App& app, const std::vector<EventRecord>& records, EventG
app.rows_b->set_row_count(rows);
if (app.area_b != nullptr) app.area_b->set_figure(&app.rows_b->figure());
}
// The user's layout is read **first**, and then every slot is freed on both plots, before the group
// assigns its own. The order matters: the layout is what says which slots the group fills, so
// clearing first would clear the very thing being read (it did -- every slot came back empty and the
// comparison drew nothing). And the clear itself is what stops a row carrying the previous view's
// traces -- an accumulation's layers, a live session's signals -- into the new one, where a trace
// left behind is an extra legend entry in a colour belonging to nothing on screen.
struct SlotPlan {
std::string name;
std::string label;
int colour = 0;
};
// ... and only the *base* layout is read back: a slot is part of it when its name carries no layer
// suffix or layer 0's own (`(A)`). A group's *extra* layers -- `(B)`, `#2` -- are that group's own
// artefacts, and reading them back as the user's arrangement is how an accumulation's layers survived
// into the next comparison: extra legend entries, in colours belonging to nothing.
const auto base_slot = [](const std::string& name) {
return name == layer_base_name(name) ||
(name.size() > 4 && name.compare(name.size() - 4, 4, " (A)") == 0);
};
std::vector<std::array<SlotPlan, RowSet::kSlotsPerRow>> layout(static_cast<std::size_t>(rows));
for (int r = 0; r < rows; ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t at = static_cast<std::size_t>(s);
const std::string& name = app.rows->row(r).signal_name[at];
if (!base_slot(name)) continue;
layout[static_cast<std::size_t>(r)][at] =
SlotPlan{name, app.rows->row(r).name[at], app.rows->row(r).color_index[at]};
}
}
const auto clear = [](RowSet* rows) {
if (rows == nullptr) return;
for (int r = 0; r < rows->row_count(); ++r) {
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) rows->assign(r, s, "", "", 0);
}
};
clear(app.rows);
clear(app.rows_b.get());
app.rows->set_trace_alpha(kind == EventGroupKind::Accumulate ? 0.35f : 1.0f);
if (app.rows_b != nullptr) app.rows_b->set_trace_alpha(1.0f);
for (int r = 0; r < rows; ++r) {
RowSet::Row& row = app.rows->row(r);
const std::array<std::string, RowSet::kSlotsPerRow> names = row.signal_name;
const std::array<std::string, RowSet::kSlotsPerRow> labels = row.name;
const std::array<int, RowSet::kSlotsPerRow> colours = row.color_index;
const std::array<SlotPlan, RowSet::kSlotsPerRow>& plan = layout[static_cast<std::size_t>(r)];
for (int s = 0; s < RowSet::kSlotsPerRow; ++s) {
const std::size_t at = static_cast<std::size_t>(s);
if (names[at].empty()) continue;
const std::string base = base_name(names[at]);
app.rows->assign(r, s, base + suffix(0), labels[at], colours[at]);
if (plan[at].name.empty()) continue;
const std::string base = base_name(plan[at].name);
app.rows->assign(r, s, base + suffix(0), plan[at].label, plan[at].colour);
if (pair) {
// The *same* colour index: the two plots show the same signal of two different events, and a
// colour of its own for the second would say they were different signals.
app.rows_b->assign(r, s, base + suffix(1), labels[at] + suffix(1), colours[at]);
app.rows_b->assign(r, s, base + suffix(1), plan[at].label + suffix(1), plan[at].colour);
continue;
}
// The extra layers go into free slots of **this** row, and nowhere else. A row's signals are what
// its colours and its legend speak for, so a layer placed in another row put a signal there in the
// colour of the signal it came from -- a multi-row layout whose other rows came out "wrong". A row
// with no room accumulates as far as it can and stops.
for (std::size_t layer = 1; layer < layers; ++layer) {
int found_row = -1;
int found_slot = -1;
for (int pass = 0; pass < 2 && found_row < 0; ++pass) {
const int from = pass == 0 ? r : 0;
const int to = pass == 0 ? r + 1 : rows;
for (int rr = from; rr < to && found_row < 0; ++rr) {
for (int ss = 0; ss < RowSet::kSlotsPerRow; ++ss) {
if (!app.rows->row(rr).signal_name[static_cast<std::size_t>(ss)].empty()) continue;
found_row = rr;
found_slot = ss;
break;
}
}
for (int ss = 0; ss < RowSet::kSlotsPerRow; ++ss) {
if (!app.rows->row(r).signal_name[static_cast<std::size_t>(ss)].empty()) continue;
found_slot = ss;
break;
}
if (found_row < 0) break; // the layout is full: no room for another layer
app.rows->assign(found_row, found_slot, base + suffix(layer), labels[at] + suffix(layer),
colours[at]);
if (found_slot < 0) break; // this row is full: its signal accumulates as far as it can
app.rows->assign(r, found_slot, base + suffix(layer), plan[at].label + suffix(layer),
plan[at].colour);
}
}
}
@@ -435,7 +475,7 @@ bool build_event_group(App& app, const std::vector<EventRecord>& records, EventG
rebuild_layout_editor(app);
rebuild_signal_panels(app);
// Fit on open, once: the group's own span, framed.
apply_time_window(app, span);
// As above: the tick pins the axis to the group's own span and marks the trigger.
sync_events_availability(app);
return true;
}
@@ -680,16 +720,25 @@ void refresh_events_pane(App& app) {
gtk_box_append(GTK_BOX(box), name);
char sub[160];
std::snprintf(sub, sizeof(sub), "%zu signals · %zu samples · %.4g S/s", r.signals, r.samples,
r.rate_hz);
std::snprintf(sub, sizeof(sub), "%zu signals · %zu samples", r.signals, r.samples);
GtkWidget* detail = gtk_label_new(sub);
gtk_widget_add_css_class(detail, "dim-label");
gtk_widget_set_halign(detail, GTK_ALIGN_START);
gtk_label_set_max_width_chars(GTK_LABEL(detail), 22);
// Both labels ellipsize, so the text gives way to the picture rather than the picture to the
// text: a label's minimum width is its longest word otherwise, and the pane is 250 pixels.
gtk_label_set_ellipsize(GTK_LABEL(detail), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(box), detail);
// A third line: the rate it was recorded at, where its edge sits and which latch it was -- what
// tells two events of the same length apart once the picture is too small to read.
char third[160];
std::snprintf(third, sizeof(third), "%.4g S/s · edge %.4g s · #%llu", r.rate_hz, r.edge,
static_cast<unsigned long long>(r.seq));
GtkWidget* extra = gtk_label_new(third);
gtk_widget_add_css_class(extra, "dim-label");
gtk_widget_set_halign(extra, GTK_ALIGN_START);
gtk_label_set_max_width_chars(GTK_LABEL(extra), 22);
gtk_label_set_ellipsize(GTK_LABEL(extra), PANGO_ELLIPSIZE_END);
gtk_box_append(GTK_BOX(box), extra);
gtk_box_append(GTK_BOX(line), box);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);