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:
1 parent
8f54841c2c
commit
0e256764d5
41 files changed
+2081
-154
No files matched your search
@@ -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
|
||||
Reference in new issue
Block a user