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
+71
-6
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user