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

@@ -65,6 +65,19 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
data the scope never had. The two are one branch in `tick()` for that reason. A paused view
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
picture is equally immovable; resuming or leaving trigger mode clears it.
- **An opened file is a pinned view too, and *how* it is pinned matters as much as that it is.** The
file view states its pin from the ring's own stamps every frame, and it used to do that with
`pin_event_window()` -- which writes the group's range as well as the pin's numbers -- so a box zoom
drew its frame and changed nothing: the controller's `set_x_range` went onto the *member*, the next
frame's re-statement put the group's range back, and the branch it fell into (`clamp_to_data()`,
because a file is neither `event_held` nor `Paused` nor `capturing`) never adopts a member into a
group that is not following. That is the wheel-in-single-trigger defect again, in the one mode nobody
re-tested. Two things hold it now: the branch is `Paused || capturing || rows->window_pinned()`, so a
pinned view is always clamped by `hold_pinned_window()`; and the file's per-frame statement uses
`set_data_pin()` -- the pin's numbers -- unless `pinned_window_is()` says the pin is a *different*
window, which is a re-open with a new extent and is a new event, stated in full. `[boxzoom-probe]`'s
`zoomed=` is the guard, and it is a reading to take in **both** modes: live and file must both read
`1`.
- **A pinned view is navigable *within its data*, and that is the bound -- not the request.**
Two things had to agree for this, and both were wrong in the same direction:
the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut