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

+71 -6
View File
@@ -6,6 +6,8 @@
#include "waveform.hpp"
#include "layout_store.hpp"
#include "settings.hpp"
#include "ui_prefs.hpp"
#include <adwaita.h>
#include <gtk/gtk.h>
@@ -32,7 +34,10 @@ void apply_source(Config& cfg, const KnownSource& src) {
// only moment a buffer size can be applied at all -- and the reason they are stored per
// configuration rather than only passed on the command line.
if (src.buffer_mb > 0.0) cfg.buffer_mb = src.buffer_mb;
cfg.relative_time = src.relative_time;
// The list's own copy of the clock, but only where nothing newer has said: with the preferences
// open from this dialog a user can state relative time a minute before clicking the row, and that
// statement must survive the click that follows it.
if (!cfg.relative_time_specified) cfg.relative_time = src.relative_time;
cfg.capture_packets = src.capture_packets;
}
@@ -100,6 +105,10 @@ struct Chooser {
/// list with it, so the row a report would name is gone by the time the probe reads it.
std::string saved_config;
std::string saved_note;
/// The preferences, for the button below. Same reasoning as `form` above: a member of the chooser
/// rather than a heap struct, so nothing can outlive the stack frame that owns it.
ui_impl::PrefsForm prefs;
};
void chooser_finish(Chooser& c, bool chosen) {
@@ -114,6 +123,13 @@ void chooser_finish(Chooser& c, bool chosen) {
c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter
gtk_window_destroy(GTK_WINDOW(w));
}
// The preferences window is transient for the chooser, so it has to go first: a window destroyed
// with a transient still up is the lifetime this project has already got wrong once.
if (c.prefs.window != nullptr) {
GtkWidget* w = c.prefs.window;
c.prefs.window = nullptr;
gtk_window_destroy(GTK_WINDOW(w));
}
if (c.window != nullptr) {
GtkWidget* w = c.window;
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
@@ -577,6 +593,23 @@ gboolean on_chooser_key(GtkEventControllerKey*, guint keyval, guint, GdkModifier
return TRUE;
}
/// The chooser's way into the preferences -- and the reason the form has no owner: this dialog runs
/// before `App` exists. Relative time has to reach the run this chooser is about to start, because
/// `main()` plans the buffers only after this returns; the rest is read from the file when that run
/// starts, so it is honoured without anything here doing it.
void open_chooser_preferences(Chooser& c) {
ui_impl::prefs_open(c.prefs, c.window, load_settings(), [&c](const Settings& s, bool written) {
// The newest statement of intent there is for this run: it beats the list's own copy and the
// stored configuration, and it works in both directions -- turning it on *and* off.
c.cfg->relative_time = s.relative_time;
c.cfg->relative_time_specified = true;
if (!written) {
std::fprintf(stderr, "[sources] prefs: could not write %s\n", settings_path().c_str());
std::fflush(stderr);
}
});
}
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
/// dialog built, filled and destroyed is as much the point as the values, the same way
@@ -588,6 +621,24 @@ gboolean pick_probe(gpointer user) {
// -probe call would otherwise be a GLib critical on every run that let the probe fire.
c.pick_source = 0;
// `UDPSCOPE_PROBE_CHOOSER_PREFS=1`: the preferences opened from *here* rather than from the app's
// menu -- the entry point that exists because this dialog runs before `App`. It is driven through
// the same fill-and-apply the app's probe uses, and reports what the run about to start will get,
// which is the half of the contract the app's probe cannot see. The window is deliberately left
// open: the pick below destroys the chooser with it still up, which is the lifetime at risk.
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_PREFS") != nullptr) {
open_chooser_preferences(c);
std::fprintf(stderr, "[sources] prefs: opened=%d apply=%d\n", c.prefs.window != nullptr ? 1 : 0,
c.prefs.apply != nullptr ? 1 : 0);
ui_impl::prefs_probe_fill_and_apply(c.prefs);
const Settings back = load_settings();
std::fprintf(stderr,
"[sources] prefs: dir=%s keep=%d rel=%d theme=%s cfg_relative=%d\n",
back.event_cache_dir.c_str(), back.event_cache_max, back.relative_time ? 1 : 0,
back.theme.c_str(), c.cfg->relative_time ? 1 : 0);
std::fflush(stderr);
}
// `UDPSCOPE_PROBE_WAVEFORMS=1` is the waveforms page without a display: the recent list, and the
// page selected the way a click on its tab does. `UDPSCOPE_PROBE_WAVE=<path>` is the file dialog's
// answer without the dialog -- the same field, set the same way.
@@ -701,13 +752,14 @@ void apply_stored_settings(Config& cfg) {
if (want.empty()) return;
LayoutState st;
if (!load_layout(configuration_path(cfg.server, cfg.port, want), st)) return;
// Only where the command line was silent: a flag the user typed wins, because it is the more
// recent statement of intent -- and the file is not a place to hide a surprise from the ones who
// did type it.
// Only where nothing newer was said: a flag the user typed, or the preferences they just applied,
// wins -- because it is the more recent statement of intent, and the file is not a place to hide a
// surprise from whoever just made it.
if (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) cfg.buffer_mb = st.buffer_mb;
if (st.relative_time) cfg.relative_time = true;
const bool relative = !cfg.relative_time_specified && st.relative_time;
if (relative) cfg.relative_time = true;
if (cfg.capture_packets == 0 && st.capture_packets > 0) cfg.capture_packets = st.capture_packets;
const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || st.relative_time ||
const bool applied = (!cfg.buffer_mb_specified && st.buffer_mb > 0.0) || relative ||
st.capture_packets > 0;
if (applied) {
std::fprintf(stderr,
@@ -788,6 +840,18 @@ bool choose_source(Config& cfg) {
gtk_widget_set_margin_top(actions, 12);
gtk_widget_set_margin_bottom(actions, 12);
gtk_widget_set_halign(actions, GTK_ALIGN_END);
// The preferences are reachable from here as well as from the main window's menu: this dialog is
// the only window that exists before anything is planned, so it is where a user still has the
// chance to say how this run should behave.
GtkWidget* prefs = gtk_button_new_with_label("Preferences\xe2\x80\xa6");
gtk_widget_set_tooltip_text(prefs,
"the event cache, relative time and the interface's theme -- the same "
"dialog as the main window's menu, and the only place relative time can "
"still change this run");
g_signal_connect(prefs, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_chooser_preferences(*static_cast<Chooser*>(user));
}),
&c);
GtkWidget* new_host = gtk_button_new_with_label("New host\xe2\x80\xa6");
g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
open_form(*static_cast<Chooser*>(user));
@@ -798,6 +862,7 @@ bool choose_source(Config& cfg) {
chooser_finish(*static_cast<Chooser*>(user), false);
}),
&c);
gtk_box_append(GTK_BOX(actions), prefs);
gtk_box_append(GTK_BOX(actions), new_host);
gtk_box_append(GTK_BOX(actions), cancel);
gtk_box_append(GTK_BOX(box), actions);