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

+268
View File
@@ -0,0 +1,268 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The preferences form: the values `settings.hpp` holds, in one place, with the settings file behind
// them. It is a *view* of that struct -- it reads one, edits it and hands it back -- so nothing here
// decides what a preference means; `PrefsForm::on_applied` is where that happens, and it happens
// differently in the app and in the source chooser.
//
// Two rows can only take effect on the next start in the app's case (the cache directory and relative
// time: the buffers are planned and the cache is opened before any stream is known), and the dialog
// says so rather than pretending otherwise. From the chooser, both are honoured by the run the
// chooser is about to start, because that run has not been planned yet.
#include "ui_prefs.hpp"
#include "event_store.hpp" // `default_events_dir()`, so the field shows the path the cache uses
#include "ui_app.hpp" // the app's own half of the contract, at the bottom of this file
#include <adwaita.h>
#include <utility>
namespace udpscope {
namespace ui_impl {
namespace {
/// Turns the theme name into the colour scheme libadwaita understands. "auto" is `DEFAULT`, which is
/// the system's own answer -- the one a user who has not chosen expects.
AdwColorScheme theme_scheme(const std::string& name) {
if (name == "light") return ADW_COLOR_SCHEME_FORCE_LIGHT;
if (name == "dark") return ADW_COLOR_SCHEME_FORCE_DARK;
return ADW_COLOR_SCHEME_DEFAULT;
}
/// Reads the form into a `Settings`. It starts from the file rather than from an empty struct so that
/// a field this dialog does not show is carried through untouched instead of being reset by every
/// apply.
Settings read_form(const PrefsForm& f) {
Settings s = load_settings();
s.event_cache_dir = gtk_editable_get_text(GTK_EDITABLE(f.dir_entry));
// The field shows the *effective* directory, so the default is visible in full rather than described.
// Typing it back unchanged is not a choice to pin it: the file keeps `unset`, and the default stays
// the default of whatever cache directory the next run happens to have.
if (s.event_cache_dir == default_events_dir()) s.event_cache_dir.clear();
s.event_cache_max = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(f.keep_spin));
s.relative_time = gtk_switch_get_active(GTK_SWITCH(f.relative_switch));
const guint selected = gtk_drop_down_get_selected(GTK_DROP_DOWN(f.theme_drop));
s.theme = selected == 1 ? "light" : selected == 2 ? "dark" : "auto";
return s;
}
/// Cancelling must change *nothing*: the field keeps what it had and the status line stays quiet. The
/// null check is not defensive padding -- `prefs_close()` clears every widget pointer, and a folder
/// chooser still open when its window closes is exactly how a callback lands on a dead field.
void on_browse_done(GObject* source, GAsyncResult* result, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
GFile* folder = gtk_file_dialog_select_folder_finish(GTK_FILE_DIALOG(source), result, nullptr);
if (folder == nullptr) return;
char* path = g_file_get_path(folder);
g_object_unref(folder);
if (path == nullptr) return;
if (f.dir_entry != nullptr) gtk_editable_set_text(GTK_EDITABLE(f.dir_entry), path);
g_free(path);
}
/// `GtkFileDialog`, the same one the source chooser's *Open…* uses, rather than the older
/// `GtkFileChooserDialog`: on a desktop with a portal it hands over to the system's own picker instead
/// of drawing a GTK-shaped impression of one.
void on_browse(GtkButton*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
GtkFileDialog* dialog = gtk_file_dialog_new();
gtk_file_dialog_set_title(dialog, "Event cache directory");
const char* current =
f.dir_entry != nullptr ? gtk_editable_get_text(GTK_EDITABLE(f.dir_entry)) : "";
if (current != nullptr && current[0] != '\0') {
GFile* folder = g_file_new_for_path(current);
gtk_file_dialog_set_initial_folder(dialog, folder);
g_object_unref(folder);
}
gtk_file_dialog_select_folder(dialog, f.window != nullptr ? GTK_WINDOW(f.window) : nullptr, nullptr,
on_browse_done, &f);
g_object_unref(dialog); // the call holds its own reference
}
void on_defaults(GtkButton*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
const Settings d = default_settings();
gtk_editable_set_text(GTK_EDITABLE(f.dir_entry),
(d.event_cache_dir.empty() ? default_events_dir() : d.event_cache_dir).c_str());
gtk_spin_button_set_value(GTK_SPIN_BUTTON(f.keep_spin), d.event_cache_max);
gtk_switch_set_active(GTK_SWITCH(f.relative_switch), d.relative_time);
gtk_drop_down_set_selected(GTK_DROP_DOWN(f.theme_drop), 0);
}
void on_apply(GtkButton*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
const Settings s = read_form(f);
const bool written = save_settings(s);
// The theme goes on even when the write failed: the user asked to see it, and the file is the
// memory of the choice rather than the choice itself. The status line is what says it will not
// survive a restart.
apply_theme(s.theme);
if (f.status != nullptr) {
gtk_label_set_text(GTK_LABEL(f.status),
written ? "" : "could not write the preferences file -- this session only");
gtk_widget_set_visible(f.status, !written);
}
if (f.on_applied) f.on_applied(s, written);
}
/// The one close path, whether it came from the button, the window manager or `prefs_close()`.
gboolean on_close_request(GtkWindow*, gpointer user) {
PrefsForm& f = *static_cast<PrefsForm*>(user);
f.window = nullptr;
f.dir_entry = nullptr;
f.browse = nullptr;
f.keep_spin = nullptr;
f.relative_switch = nullptr;
f.theme_drop = nullptr;
f.apply = nullptr;
f.status = nullptr;
return FALSE; // the default handler destroys the window
}
void on_close_clicked(GtkButton*, gpointer user) {
prefs_close(*static_cast<PrefsForm*>(user));
}
} // namespace
void prefs_open(PrefsForm& form, GtkWidget* parent, const Settings& current,
std::function<void(const Settings&, bool written)> on_applied) {
if (form.window != nullptr) {
gtk_window_present(GTK_WINDOW(form.window));
return;
}
form.on_applied = std::move(on_applied);
GtkWidget* window = dialog_window(parent, "Preferences");
form.window = window;
g_signal_connect(window, "close-request", G_CALLBACK(on_close_request), &form);
GtkWidget* column = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_margin_top(column, 12);
gtk_widget_set_margin_bottom(column, 12);
gtk_widget_set_margin_start(column, 12);
gtk_widget_set_margin_end(column, 12);
// --- the event cache ------------------------------------------------------
GtkWidget* cache_heading = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(cache_heading), "<b>Event cache</b>");
gtk_widget_set_halign(cache_heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(column), cache_heading);
form.dir_entry = gtk_entry_new();
gtk_editable_set_text(GTK_EDITABLE(form.dir_entry),
(current.event_cache_dir.empty() ? default_events_dir()
: current.event_cache_dir)
.c_str());
gtk_widget_set_tooltip_text(form.dir_entry,
"where events are cached: one directory in which every source gets a "
"folder of its own, never one directory between two sources. Left at "
"the default shown here, it follows the cache directory");
// A standard icon rather than the word: an icon-only button needs its meaning kept somewhere, so the
// name goes to assistive technology and the explanation to the tooltip. `folder-open-symbolic` is
// Adwaita's, not one of the six this program bundles -- it is the theme's own word for "choose a
// directory", and a second drawing of it here would only be a worse one.
form.browse = gtk_button_new_from_icon_name("folder-open-symbolic");
gtk_widget_set_tooltip_text(form.browse,
"choose the directory in a file chooser; what it returns is put in "
"the field beside it, which stays the value that is stored");
// Terminated by -1, not by a null: this call is varargs, and `nullptr` reads as property id 0 --
// which is `autocomplete`, not "no more properties", so GTK then reads the next word as its value.
gtk_accessible_update_property(GTK_ACCESSIBLE(form.browse), GTK_ACCESSIBLE_PROPERTY_LABEL,
"Choose the event cache directory", -1);
g_signal_connect(form.browse, "clicked", G_CALLBACK(on_browse), &form);
GtkWidget* dir_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_box_append(GTK_BOX(dir_row), form.dir_entry);
gtk_box_append(GTK_BOX(dir_row), form.browse);
gtk_box_append(GTK_BOX(column), labeled_box("directory", dir_row));
form.keep_spin = spin(0, 100000, 1, 0, current.event_cache_max);
gtk_widget_set_tooltip_text(form.keep_spin,
"how many events one source keeps; the oldest are deleted as new ones "
"arrive. 0 keeps every event, which is a decision about the disk");
gtk_box_append(GTK_BOX(column), labeled_box("keep events", form.keep_spin));
// --- the plot -------------------------------------------------------------
GtkWidget* plot_heading = gtk_label_new(nullptr);
gtk_label_set_markup(GTK_LABEL(plot_heading), "<b>Plot</b>");
gtk_widget_set_halign(plot_heading, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(column), plot_heading);
form.relative_switch = gtk_switch_new();
gtk_switch_set_active(GTK_SWITCH(form.relative_switch), current.relative_time);
gtk_widget_set_tooltip_text(form.relative_switch,
"ignore the stream's own clock and plot from the sample rate and the "
"sample index, for a source whose time stamps jitter");
gtk_box_append(GTK_BOX(column), labeled_switch_row("relative time", form.relative_switch));
const char* themes[] = {"auto", "light", "dark", nullptr};
form.theme_drop = gtk_drop_down_new_from_strings(themes);
gtk_drop_down_set_selected(GTK_DROP_DOWN(form.theme_drop),
current.theme == "light" ? 1 : current.theme == "dark" ? 2 : 0);
gtk_widget_set_tooltip_text(form.theme_drop,
"what the interface looks like; auto follows the system");
gtk_box_append(GTK_BOX(column), labeled_box("theme", form.theme_drop));
// --- the way out ----------------------------------------------------------
form.status = gtk_label_new(nullptr);
gtk_label_set_wrap(GTK_LABEL(form.status), TRUE);
gtk_label_set_xalign(GTK_LABEL(form.status), 0.0F);
gtk_widget_add_css_class(form.status, "error");
gtk_widget_set_visible(form.status, FALSE);
gtk_box_append(GTK_BOX(column), form.status);
GtkWidget* buttons = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
gtk_widget_set_halign(buttons, GTK_ALIGN_END);
GtkWidget* defaults = gtk_button_new_with_label("Defaults");
g_signal_connect(defaults, "clicked", G_CALLBACK(on_defaults), &form);
gtk_box_append(GTK_BOX(buttons), defaults);
form.apply = gtk_button_new_with_label("Apply");
gtk_widget_add_css_class(form.apply, "suggested-action");
g_signal_connect(form.apply, "clicked", G_CALLBACK(on_apply), &form);
gtk_box_append(GTK_BOX(buttons), form.apply);
GtkWidget* close = gtk_button_new_with_label("Close");
g_signal_connect(close, "clicked", G_CALLBACK(on_close_clicked), &form);
gtk_box_append(GTK_BOX(buttons), close);
gtk_box_append(GTK_BOX(column), buttons);
gtk_window_set_child(GTK_WINDOW(window), column);
gtk_window_present(GTK_WINDOW(window));
}
void prefs_close(PrefsForm& form) {
if (form.window == nullptr) return;
GtkWidget* window = form.window;
form.window = nullptr; // before the close, so `close-request` cannot re-enter a stale window
gtk_window_close(GTK_WINDOW(window));
}
void apply_theme(const std::string& name) {
AdwStyleManager* manager = adw_style_manager_get_default();
if (manager != nullptr) adw_style_manager_set_color_scheme(manager, theme_scheme(name));
}
void prefs_probe_fill_and_apply(PrefsForm& form) {
if (form.window == nullptr) return;
gtk_editable_set_text(GTK_EDITABLE(form.dir_entry), "/tmp/udpscope-prefs-events");
gtk_spin_button_set_value(GTK_SPIN_BUTTON(form.keep_spin), 7);
gtk_switch_set_active(GTK_SWITCH(form.relative_switch), TRUE);
gtk_drop_down_set_selected(GTK_DROP_DOWN(form.theme_drop), 2);
if (form.apply != nullptr) g_signal_emit_by_name(form.apply, "clicked");
}
/// The app's entry point: the preferences button in the overflow menu. Kept here rather than in
/// `ui.cpp` with the rest of the chrome, because it is three lines of *this* form's contract -- what
/// the app must do when a preference changes -- and reading it next to the form is the only way to
/// see that the two agree.
void open_preferences(App& app) {
prefs_open(app.prefs, app.window, app.settings, [&app](const Settings& s, bool written) {
app.settings = s;
set_notice(app, written ? "preferences saved" : "could not write the preferences file", 4);
});
}
} // namespace ui_impl
} // namespace udpscope