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
+199
-15
@@ -613,7 +613,21 @@ void sync_layout_editor(App& app) {
|
||||
for (GtkWidget* b : app.layout_buttons) {
|
||||
const int idx = row_key(b);
|
||||
const bool insert = g_object_get_data(G_OBJECT(b), kLayoutKindKey) != nullptr;
|
||||
if (insert) {
|
||||
const bool resize = g_object_get_data(G_OBJECT(b), kLayoutResizeKey) != nullptr;
|
||||
if (resize) {
|
||||
// *On* the boundary, beside the `+` -- the same edge, one handle's width to its right. Below it sat
|
||||
// inside the lower row, which read as a handle for that row: a boundary handle that looks like it
|
||||
// belongs to the plot under it is a handle whose drag changes the wrong plot, whatever it does.
|
||||
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
|
||||
: (static_cast<double>(fl.rows.back().y) +
|
||||
static_cast<double>(fl.rows.back().h)) / scale;
|
||||
const double top = std::clamp(edge - half, 0.0, height - kLayoutButtonPx);
|
||||
gtk_widget_set_margin_top(b, static_cast<int>(top + 0.5));
|
||||
const double width = static_cast<double>(gtk_widget_get_width(app.area_widget));
|
||||
const double left = width * 0.5 + kLayoutButtonPx * 0.5 + 6.0;
|
||||
gtk_widget_set_margin_start(
|
||||
b, static_cast<int>(std::clamp(left, 0.0, std::max(0.0, width - kLayoutButtonPx)) + 0.5));
|
||||
} else if (insert) {
|
||||
// Centred on the boundary: the top edge of row `idx`, or the bottom edge of the
|
||||
// last row for the one past the end.
|
||||
const double edge = idx < rows ? static_cast<double>(fl.rows[static_cast<std::size_t>(idx)].y) / scale
|
||||
@@ -637,6 +651,52 @@ void sync_layout_editor(App& app) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A row may not be dragged below this, in logical pixels: a row of no height is a row a user cannot
|
||||
/// grab again, and the plotter keeps a floor of its own -- this is the one the *drag* respects.
|
||||
constexpr double kMinRowPx = 48.0;
|
||||
|
||||
void on_layout_drag_begin(GtkGestureDrag* gesture, double, double, gpointer user) {
|
||||
App& app = *static_cast<App*>(user);
|
||||
app.layout_drag_row =
|
||||
static_cast<int>(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(gesture), "udpscope-layout-drag")));
|
||||
// A drag is *relative*: the weights it started from are kept, because reading them back while dragging
|
||||
// would compound -- each update would build on the last frame's instead of on the grab.
|
||||
app.layout_drag_start = app.rows->row_weights();
|
||||
app.layout_drag_start.resize(static_cast<std::size_t>(app.rows->row_count()), 1.0f);
|
||||
}
|
||||
|
||||
void on_layout_drag_update(GtkGestureDrag*, double, double offset_y, gpointer user) {
|
||||
App& app = *static_cast<App*>(user);
|
||||
const int at = app.layout_drag_row; // the boundary between rows `at - 1` and `at`
|
||||
const int rows = app.rows->row_count();
|
||||
if (at <= 0 || at >= rows) return; // the figure's own edges are not boundaries
|
||||
if (app.layout_drag_start.size() != static_cast<std::size_t>(rows)) return;
|
||||
const plot::FigureLayout& fl = app.rows->figure().layout();
|
||||
if (static_cast<int>(fl.rows.size()) != rows) return;
|
||||
const double scale =
|
||||
app.area != nullptr && app.area->device_scale() > 0.0f ? app.area->device_scale() : 1.0;
|
||||
const double up = static_cast<double>(fl.rows[static_cast<std::size_t>(at - 1)].h) / scale;
|
||||
const double lo = static_cast<double>(fl.rows[static_cast<std::size_t>(at)].h) / scale;
|
||||
const double pair = up + lo;
|
||||
if (!(pair > kMinRowPx * 2.0)) return;
|
||||
// Where the boundary is asked to go, floored on both sides: the *pixels* say where, and the pair's
|
||||
// weights are the shares that put it there.
|
||||
const double want_up = std::clamp(up + offset_y / scale, kMinRowPx, pair - kMinRowPx);
|
||||
std::vector<float> weights = app.layout_drag_start;
|
||||
const float sum = weights[static_cast<std::size_t>(at - 1)] + weights[static_cast<std::size_t>(at)];
|
||||
weights[static_cast<std::size_t>(at - 1)] = sum * static_cast<float>(want_up / pair);
|
||||
weights[static_cast<std::size_t>(at)] = sum - weights[static_cast<std::size_t>(at - 1)];
|
||||
app.rows->set_row_weights(std::move(weights));
|
||||
}
|
||||
|
||||
void on_layout_drag_end(GtkGestureDrag*, double, double, gpointer user) {
|
||||
App& app = *static_cast<App*>(user);
|
||||
app.layout_drag_row = -1;
|
||||
app.layout_drag_start.clear();
|
||||
// A dragged sizing is an arrangement, so it is saved like one.
|
||||
schedule_layout_save(app);
|
||||
}
|
||||
|
||||
/// Builds one `×` per row and one `+` per boundary, or takes them all off again when the
|
||||
/// editor is switched off.
|
||||
void rebuild_layout_editor(App& app) {
|
||||
@@ -676,6 +736,39 @@ void rebuild_layout_editor(App& app) {
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
|
||||
app.layout_buttons.push_back(b);
|
||||
}
|
||||
// ... and one arrow per boundary whose *only* job is resizing the two rows it sits between. A widget
|
||||
// of its own rather than a second gesture on the `+`, so each handle does one thing -- and a handle
|
||||
// that cannot be clicked cannot be clicked by accident. The figure's own edges are not boundaries
|
||||
// (there is no pair of rows to share between them), so the first and last are inert.
|
||||
for (int b_i = 0; b_i <= rows; ++b_i) {
|
||||
GtkWidget* b = gtk_button_new_from_icon_name("udpscope-resize-rows-symbolic");
|
||||
gtk_widget_set_tooltip_text(b, b_i == 0 ? "drag to resize the first row"
|
||||
: b_i == rows ? "drag to resize the last row"
|
||||
: "drag to resize the two rows here");
|
||||
gtk_widget_add_css_class(b, "circular");
|
||||
gtk_widget_add_css_class(b, "osd");
|
||||
gtk_widget_set_size_request(b, kLayoutButtonPx, kLayoutButtonPx);
|
||||
gtk_widget_set_halign(b, GTK_ALIGN_CENTER);
|
||||
gtk_widget_set_valign(b, GTK_ALIGN_START);
|
||||
// Hidden, not disabled, where it has no pair to share between: the figure's own edges. A greyed
|
||||
// handle is a question ("why can I not drag this?"), and the answer is that there is nothing there.
|
||||
gtk_widget_set_visible(b, b_i > 0 && b_i < rows);
|
||||
gtk_widget_set_halign(b, GTK_ALIGN_START);
|
||||
// The *row key* is what `sync_layout_editor()` places by -- without it every arrow was placed at
|
||||
// index 0, the figure's top edge, whatever boundary it belonged to.
|
||||
set_row_key(b, b_i);
|
||||
g_object_set_data(G_OBJECT(b), kLayoutResizeKey, GSIZE_TO_POINTER(b_i));
|
||||
GtkGesture* drag = gtk_gesture_drag_new();
|
||||
gtk_gesture_single_set_button(GTK_GESTURE_SINGLE(drag), 1);
|
||||
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(drag), GTK_PHASE_CAPTURE);
|
||||
g_object_set_data(G_OBJECT(drag), "udpscope-layout-drag", GSIZE_TO_POINTER(b_i));
|
||||
g_signal_connect(drag, "drag-begin", G_CALLBACK(on_layout_drag_begin), &app);
|
||||
g_signal_connect(drag, "drag-update", G_CALLBACK(on_layout_drag_update), &app);
|
||||
g_signal_connect(drag, "drag-end", G_CALLBACK(on_layout_drag_end), &app);
|
||||
gtk_widget_add_controller(b, GTK_EVENT_CONTROLLER(drag));
|
||||
gtk_overlay_add_overlay(GTK_OVERLAY(app.area_overlay), b);
|
||||
app.layout_buttons.push_back(b);
|
||||
}
|
||||
|
||||
// The editor's way out, in the area's top-right corner: the toolbar's toggle is in the
|
||||
// overflow menu, which is a long way from the plot you are editing. Aligned by margins
|
||||
@@ -3149,7 +3242,13 @@ gboolean tick(gpointer user) {
|
||||
// bound to nothing and the snapshot carries its own `x_offset`, so the epoch is 0 and
|
||||
// `ScopeModel::stream_at_x0()` is what crosses between the clocks.
|
||||
double want_epoch = 0.0;
|
||||
if (display == DisplayMode::Capturing) {
|
||||
if (!app->waveform_name.empty()) {
|
||||
// A file *is* an event: an export's stamps are already trigger-relative, so the trigger instant
|
||||
// is x = 0 and the epoch must not move it. The live rule below ("the newest sample sits at 0")
|
||||
// would put the trigger at the right edge and push the whole pre-trigger half off the screen --
|
||||
// which is what "I only see 0 to 0.2 of a -0.8 to 0.2 recording" was.
|
||||
want_epoch = 0.0;
|
||||
} else if (display == DisplayMode::Capturing) {
|
||||
want_epoch = -cap_edge;
|
||||
} else if (model.display_snapshot() == nullptr) {
|
||||
// `live_edge()` reports in the epoch currently in force, so subtracting it is minus the
|
||||
@@ -3166,13 +3265,53 @@ gboolean tick(gpointer user) {
|
||||
// Outside the lock: it reads and writes the two plots' own windows, not the model.
|
||||
sync_compare_axes(*app);
|
||||
sync_events_divider(*app);
|
||||
// The comparison's plot needs the same re-point the primary gets from `relayout_areas()`: a
|
||||
// `RowSet` rebuilds its *figure* when a row's trace set changes, and the area has to be handed the
|
||||
// new one. Nothing did that for the second plot, so it kept drawing the figure built when its slots
|
||||
// were still empty -- every trace in the palette's first colour, which is blue. Idempotent: the
|
||||
// area is handed the figure it already has on every other frame.
|
||||
// A file on screen is an event, and gets what a held capture gets: the axis pinned to the event's own
|
||||
// `[-pre, +post]` and the trigger instant marked at x = 0. That *is* the constraint -- the time base is
|
||||
// the event's, so no gesture can take the view outside it -- and the window control reads the event's
|
||||
// length. The extent comes from the ring itself, so nothing has to remember it: with the epoch at 0
|
||||
// the stamps are the display x.
|
||||
if (!app->waveform_name.empty() && !app->capturing && app->rows != nullptr) {
|
||||
double lo = 0.0;
|
||||
double hi = 0.0;
|
||||
{
|
||||
const auto& t = app->model->live().time();
|
||||
const std::uint64_t n = t.size();
|
||||
if (n > 0) {
|
||||
lo = t[0];
|
||||
hi = t[n - 1];
|
||||
}
|
||||
}
|
||||
if (hi > lo) {
|
||||
// State the pin **once**, then only its numbers. `pin_event_window()` writes the group's range as
|
||||
// well as the pin, and doing that every frame undid the user's box zoom before the clamp below
|
||||
// could adopt it -- the same defect the wheel had in single trigger mode, where re-stating the pin
|
||||
// made it a no-op. The held-event branch above has this shape for this reason: `set_data_pin()`
|
||||
// for the numbers, `pin_event_window()` when the view first becomes an event. A re-open into the
|
||||
// same row set with a different extent *is* a new event and re-states it.
|
||||
const auto pin_view = [lo, hi](RowSet& rows) {
|
||||
if (rows.pinned_window_is(-lo, hi)) {
|
||||
rows.set_data_pin(-lo, hi);
|
||||
} else {
|
||||
rows.pin_event_window(-lo, hi);
|
||||
}
|
||||
rows.set_trigger_marker(0.0, true);
|
||||
};
|
||||
pin_view(*app->rows);
|
||||
if (app->rows_b != nullptr) pin_view(*app->rows_b);
|
||||
sync_window_display(*app, hi - lo);
|
||||
}
|
||||
}
|
||||
// The comparison's plot needs the same re-point the primary gets from `relayout_areas()`: a `RowSet`
|
||||
// rebuilds its *figure* when a row's trace set changes, and the area has to be handed the new one.
|
||||
// Only when it *did* change, though -- `set_figure()` creates **a controller per row**, so handing it
|
||||
// the same figure every frame threw away the second plot's interaction state sixty times a second:
|
||||
// its box-zoom and its crosshair never took, while the primary's (re-pointed only on a dirty row) did.
|
||||
if (app->comparing && app->area_b != nullptr && app->rows_b != nullptr) {
|
||||
app->area_b->set_figure(&app->rows_b->figure());
|
||||
plot::Figure* want = &app->rows_b->figure();
|
||||
if (app->area_b_figure != want) {
|
||||
app->area_b->set_figure(want);
|
||||
app->area_b_figure = want;
|
||||
}
|
||||
}
|
||||
|
||||
// The gesture probe's verdicts, one frame after the ask and after this frame's clamp: a
|
||||
@@ -3258,10 +3397,18 @@ gboolean tick(gpointer user) {
|
||||
double hi = 0.0;
|
||||
if (app->rows->event_data_window(lo, hi)) app->rows->set_data_pin(lo, hi);
|
||||
app->rows->hold_pinned_window();
|
||||
} else if (display == DisplayMode::Paused || app->capturing) {
|
||||
// Pinned: the event (or the frozen pause), not a navigation surface. `clamp_to_data()` is
|
||||
// the live-mode half of the same rule -- a live view must stay on the data -- and the two
|
||||
// are complements, which is why this is one branch rather than two conditions.
|
||||
} else if (display == DisplayMode::Paused || app->capturing || app->rows->window_pinned()) {
|
||||
// Pinned: the event, the frozen pause *or an opened file*, not a navigation surface.
|
||||
// `clamp_to_data()` is the live-mode half of the same rule -- a live view must stay on the data
|
||||
// -- and the two are complements, which is why this is one branch rather than two conditions.
|
||||
//
|
||||
// A file belongs here and was the case that was missing. It pins its axis every frame (see the
|
||||
// `waveform_name` block above), but a pinned axis does *not* mean this branch: only
|
||||
// `hold_pinned_window()` adopts a gesture from the row's own axis, because a member's
|
||||
// `set_x_range` is deliberately not adopted into a group that is not following. Falling through
|
||||
// to `clamp_to_data()` therefore left a box zoom in a file view drawing its frame and changing
|
||||
// nothing -- the gesture's range went onto the member, the group kept the pin, and the picture
|
||||
// that was drawn came from the group.
|
||||
app->rows->hold_pinned_window();
|
||||
} else {
|
||||
app->rows->clamp_to_data();
|
||||
@@ -3405,7 +3552,11 @@ gboolean tick(gpointer user) {
|
||||
// being captured or held. x is seconds from the event in both: the display epoch is
|
||||
// minus the edge while the live ring is on a pinned axis, and the snapshot's own stamps
|
||||
// are edge-relative, so the edge sits at exactly x = 0 wherever the user has zoomed.
|
||||
if (!app->capturing && !(trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed))) {
|
||||
// ... and it is the *only* thing that hides it. A file's own event is on screen for as long as the
|
||||
// file is, so the file branch above must survive this one: it used to run first and be undone here,
|
||||
// which is why an opened event had no trigger line.
|
||||
if (app->waveform_name.empty() && !app->capturing &&
|
||||
!(trigger_on && model.trigger_time_valid().load(std::memory_order_relaxed))) {
|
||||
app->rows->set_trigger_marker(0.0, false);
|
||||
}
|
||||
|
||||
@@ -3588,6 +3739,15 @@ gboolean tick(gpointer user) {
|
||||
const double dt_s = std::min(10.0, double(now_c - app->last_channels_meas) / 1.0e6);
|
||||
next_channels = now_c + G_TIME_SPAN_SECOND;
|
||||
app->last_channels_meas = now_c;
|
||||
// The cache's own question, on the same cadence as the channel dump: a directory read per second,
|
||||
// and the button's availability re-decided only when the answer changed.
|
||||
if (app->event_store != nullptr) {
|
||||
const bool cached = !app->event_store->list().empty();
|
||||
if (cached != app->events_cached) {
|
||||
app->events_cached = cached;
|
||||
sync_events_availability(*app);
|
||||
}
|
||||
}
|
||||
// Once a second, alongside the rate measurement: is the stream arriving at all? Nothing is
|
||||
// owed in file mode -- there is no socket and no source -- so a file is never starved.
|
||||
if (app->waveform_name.empty()) note_starvation(*app);
|
||||
@@ -4074,6 +4234,13 @@ GtkWidget* build_toolbar(App& app) {
|
||||
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
|
||||
gtk_box_append(GTK_BOX(menu_box), help_item);
|
||||
|
||||
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
|
||||
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
open_preferences(*static_cast<App*>(user));
|
||||
}),
|
||||
&app);
|
||||
gtk_box_append(GTK_BOX(menu_box), prefs_item);
|
||||
gtk_box_append(GTK_BOX(menu_box), menu_separator());
|
||||
GtkWidget* about_item = menu_button("About udpscope", nullptr);
|
||||
gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths");
|
||||
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
|
||||
@@ -4169,13 +4336,18 @@ GtkWidget* build_toolbar(App& app) {
|
||||
if (open) refresh_events_pane(*a);
|
||||
}),
|
||||
&app);
|
||||
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.events_toggle);
|
||||
|
||||
|
||||
app.pause_button = gtk_toggle_button_new();
|
||||
gtk_button_set_icon_name(GTK_BUTTON(app.pause_button), "media-playback-pause-symbolic");
|
||||
gtk_widget_set_tooltip_text(app.pause_button, "pause acquisition");
|
||||
g_signal_connect(app.pause_button, "toggled", G_CALLBACK(on_pause_toggled), &app);
|
||||
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.pause_button);
|
||||
// A separator, because the events button is not another mode on the plot: what stands to its left is
|
||||
// what the *view* is doing (the window, the trigger, the pause) and what follows is the cache of what
|
||||
// those produced.
|
||||
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), gtk_separator_new(GTK_ORIENTATION_VERTICAL));
|
||||
gtk_header_bar_pack_start(GTK_HEADER_BAR(bar), app.events_toggle);
|
||||
|
||||
// Packed here, after every widget above has been created: the *pack* order is what decides the
|
||||
// layout, not the creation order, and packing before the widget exists passes NULL to the header
|
||||
@@ -4941,6 +5113,11 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
// Installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog`
|
||||
// needs to be drawn at all. Idempotent, and `choose_source()` may already have run it.
|
||||
adw_init();
|
||||
// The preferences are read before anything is built: the theme decides what the window looks like,
|
||||
// and the rest is in force from the first frame. A *local* until `App` exists below, which is why
|
||||
// this one read happens here and the assignment after it.
|
||||
const Settings startup_settings = load_settings();
|
||||
apply_theme(startup_settings.theme);
|
||||
// The app's own icons, before anything asks for one. They are a GResource compiled into the
|
||||
// binary (see CMakeLists), and this is what makes the theme look there: an unregistered path
|
||||
// is not an error, it is a missing-image placeholder.
|
||||
@@ -4952,6 +5129,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
gtk_icon_theme_add_resource_path(gtk_icon_theme_get_for_display(gdk_display_get_default()),
|
||||
"/org/udpscope/icons");
|
||||
App app;
|
||||
app.settings = startup_settings;
|
||||
app.model = &model;
|
||||
app.acq = &acq;
|
||||
app.rows = &rows;
|
||||
@@ -4969,7 +5147,7 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
// file-mode run has no cache either -- there are no latches in a file.
|
||||
if (!app.source_host.empty() && app.source_port != 0) {
|
||||
app.event_store = std::make_unique<EventStore>(app.source_host + "-" +
|
||||
std::to_string(app.source_port));
|
||||
std::to_string(app.source_port), app.settings.event_cache_dir);
|
||||
}
|
||||
|
||||
refresh_events_pane(app);
|
||||
@@ -5074,6 +5252,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_DEFAULTS_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), defaults_probe, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_BOXZOOM_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), boxzoom_probe, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_PREFS_MS")) {
|
||||
g_timeout_add(static_cast<guint>(std::atoi(env)), prefs_probe, &app);
|
||||
}
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_CAPTURE_MS")) {
|
||||
capture_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user