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

+39 -2
View File
@@ -27,6 +27,8 @@
#include "model.hpp"
#include "plot_host.hpp"
#include "search.hpp"
#include "settings.hpp"
#include "ui_prefs.hpp"
#include "shortcuts.hpp"
#include "status_text.hpp"
#include "waveform.hpp"
@@ -77,6 +79,10 @@ struct App {
/// The editor's buttons, in the order they were added, so they can be taken off the
/// overlay again. Rebuilt when the row count changes, repositioned when the rows move.
std::vector<GtkWidget*> layout_buttons;
/// The boundary a resize drag is on, and the weights it started from: a drag is *relative* to where it
/// began, so the beginning has to be kept -- reading the weights while dragging would compound.
int layout_drag_row = -1;
std::vector<float> layout_drag_start;
/// The editor's way out: one `Done` button in the plot area's top-right corner while
/// the editor is up. Not part of `layout_buttons`, which is one button per row and per
/// boundary -- the count the layout probe asserts.
@@ -362,6 +368,9 @@ struct App {
/// `event_recording` is the trigger panel's switch and defaults on -- the cache is only useful if
/// it was already on when the event you wanted happened.
std::unique_ptr<EventStore> event_store;
/// Whether the cache holds anything, refreshed once a second: what keeps the history button on the bar
/// for a source whose events are already written, even with recording switched off.
bool events_cached = false;
bool event_recording = false;
/// The events pane: its list, the revealer that holds it, the recording switch (in the trigger
/// panel, where the trigger's options live), and `events_thumb` -- the picture of the newest
@@ -385,6 +394,9 @@ struct App {
plot::Plot placeholder_plot_b;
plot::gtk4::PlotArea* area_b = nullptr;
GtkWidget* area_b_widget = nullptr;
/// The figure the second area was last pointed at. `set_figure()` rebuilds its per-row input
/// controllers, so it is only called when the `RowSet` really has a new figure.
plot::Figure* area_b_figure = nullptr;
std::unique_ptr<RowSet> rows_b;
GtkWidget* plot_split = nullptr;
bool comparing = false;
@@ -407,6 +419,15 @@ struct App {
/// live source. Nothing arrives in this mode, so everything that would report on a stream --
/// the popover's Stream row, the starvation notice -- reports on the file instead.
std::string waveform_name;
/// The preferences, as read at start-up and written by the dialog. Held here rather than read where
/// they are used: the dialog edits a copy, saves it and hands it back, so the file and the running
/// program cannot disagree about what a preference is.
Settings settings;
/// The preferences window while it is up, and its controls: one at a time, like the configurations
/// dialog -- a second window editing the same settings would let the two disagree.
/// The preferences window. A `PrefsForm` rather than fields here because the source chooser opens
/// the same dialog and runs before `App` exists -- see `ui_prefs.hpp`.
PrefsForm prefs;
/// The configurations dialog, while it is up: one at a time, and this is what makes a second
/// `Configurations…` a present rather than a second window.
GtkWidget* config_dialog = nullptr;
@@ -979,6 +1000,10 @@ constexpr int kRowKeyBias = 1;
constexpr int kLayoutButtonPx = 24;
constexpr int kLayoutButtonPad = 4;
constexpr const char* kLayoutKindKey = "udpscope-layout-insert";
/// The ↕ handles' own key: what they are is *not* what the `+` is, and `sync_layout_editor()`
/// places them differently -- one boundary, two controls.
constexpr const char* kLayoutResizeKey = "udpscope-layout-resize";
constexpr gint64 kMeasureRefreshUs = 100 * 1000;
constexpr gint64 kMeasureHeavyUs = 5 * 1000;
constexpr const char* kCursorModeNames[] = {"vertical", "horizontal", "both", "mix"};
@@ -992,8 +1017,8 @@ constexpr double kMathZoomStep = 1.1;
constexpr double kWirePick = 6.0;
constexpr int kZoomGestureSteps = 8;
constexpr int kZoomWindowSteps = 8;
constexpr int kEventThumbW = 126;
constexpr int kEventThumbH = 42;
constexpr int kEventThumbW = 138;
constexpr int kEventThumbH = 46;
inline int g_stats_phase = 0;
inline std::vector<std::uint64_t> g_stats_n_before;
inline double g_trigrenum_edge = 0.0;
@@ -1090,6 +1115,18 @@ void update_search_panel(App& app);
void sync_events_availability(App& app);
/// Restates the events pane's divider position once a frame; see the definition.
void sync_events_divider(App& app);
/// The preferences dialog: one window, opened from the overflow menu. `apply_theme()` is separate
/// because the theme is the one preference that takes effect without a restart.
void open_preferences(App& app);
gboolean prefs_probe(gpointer user);
gboolean boxzoom_probe(gpointer user);
/// A labelled row whose control keeps its natural width -- what a `GtkSwitch` needs and what
/// `labeled_box()`'s expanding child denies it.
GtkWidget* labeled_switch_row(const char* label, GtkWidget* control);
/// The layout editor's boundary drag: the press starts it, movement sizes the two rows it separates.
void on_layout_drag_begin(GtkGestureDrag* gesture, double x, double y, gpointer user);
void on_layout_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y, gpointer user);
void on_layout_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y, gpointer user);
void on_trigger_toggled(GtkToggleButton* button, gpointer user);
void on_go_live(GtkButton*, gpointer user);
plot::u32 measure_color(std::size_t index);