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
@@ -564,7 +564,15 @@ Two lines of the self-check output are assertions in disguise:
|
||||
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
|
||||
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
|
||||
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
|
||||
- `[help] pages=14 parsed=14 shortest='Choosing a source' 1563 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
|
||||
- `[help] pages=14 parsed=14 shortest='Pause and play' 1627 B; shortcuts: app=12 plot=14 tables=26 ui=26 ui_accel=19 ui_icon=7 ui_bare=0; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. The `shortcuts:` numbers are the shortcut overview four ways.
|
||||
`app` and `plot` are the rows in the C tables -- what the application actually *handles* -- and `tables`
|
||||
is their sum. `ui`, `ui_accel`, `ui_icon` and `ui_bare` are what `src/ui/shortcuts.ui` *declares*, read
|
||||
out of the resource with a markup parse rather than off the built widgets, because a tree walk counts
|
||||
every row twice: `GtkShortcutsWindow` reaches each one from more than one place in its own tree.
|
||||
**`ui` must equal `tables`** -- two lists of one thing, and a row in one but not the other is a gesture
|
||||
that works and is not documented, or is documented and does nothing -- and **`ui_bare` must be zero**: a
|
||||
row with no accelerator and no icon is a cell reading "No Shortcut". `ui_accel + ui_icon == ui`, so those
|
||||
two also catch a row whose accelerator went missing. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
|
||||
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
|
||||
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
|
||||
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
|
||||
@@ -892,6 +900,33 @@ popover row reads `failed: <strerror>` — one stderr line, no retry storm again
|
||||
| `known:` after a rename | one row, under the new name | a rename that leaves the old file, so the chooser lists the same session twice |
|
||||
| `known:` for a settings-only configuration | a row, though `signals=0` | the listing filter treating "no signals yet" as "nobody configured this", which hides every host the form set up |
|
||||
|
||||
## `[sources] prefs`
|
||||
|
||||
```
|
||||
[sources] prefs: opened=1 apply=1
|
||||
[sources] prefs: dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark cfg_relative=1
|
||||
```
|
||||
|
||||
`UDPSCOPE_PROBE_PICK_MS` + `UDPSCOPE_PROBE_CHOOSER_PREFS=1` opens the preferences from the *chooser*
|
||||
rather than from the main window's menu, fills the form through the same `prefs_probe_fill_and_apply()`
|
||||
the app's probe uses, and reports both the file and `cfg_relative` -- what the run about to start will
|
||||
actually use.
|
||||
|
||||
- The second line must echo the probe's input, exactly as `[prefs-probe]` does.
|
||||
- `cfg_relative=1` is the chooser's half of the contract: relative time is the one preference that can
|
||||
still change *this* run, because `main()` plans the buffers after this dialog returns.
|
||||
- The run leaves the preferences window **open** on purpose: the pick that follows destroys the chooser
|
||||
with the window still up. A `Gtk-CRITICAL` or a crash here is the transient-window lifetime being
|
||||
wrong, not a cosmetic failure.
|
||||
- A `[sources] prefs: could not write <path>` line, with the second line reading the *defaults*
|
||||
(`dir= keep=32 rel=0 theme=auto`), is the signature of the config directory not having been created:
|
||||
the chooser is the first thing that writes preferences on a fresh install, so it is where a
|
||||
`mkdir()` that does not make its parents shows up. This reading is why that is now caught here rather
|
||||
than reported by a user whose settings quietly did not stick.
|
||||
- The line after it, `[sources] picked ... relative=1`, is the pair that matters: the preference stated
|
||||
in the dialog has to survive the row click that follows, which overwrites the rest of the config from
|
||||
the source's stored session.
|
||||
|
||||
## `[sources] settings from configuration`
|
||||
|
||||
```
|
||||
@@ -940,12 +975,12 @@ configuration that carries settings, is the defect this exists for — a saved h
|
||||
[events] live, recording off: button_visible=0 pane=0 pane_w=0 tick=1 recording=0
|
||||
[events] recording on, no trigger: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
|
||||
[events] after trigger on: button_visible=1 pane=0 pane_w=0 tick=1 recording=1
|
||||
[events] dir=/tmp/k5/cache/udpscope/127.0.0.1-21023/events n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=1.000100 signals=4 samples=20001 rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1
|
||||
[events] dir=/tmp/k5/cache/udpscope/events/127.0.0.1-21023 n=1 newest: dur=2.000000 pre=1.000000 post=1.000000 edge=1.000100 signals=4 samples=20001 rate=10000 seq=1 thumb_bytes=230454 recorded=1 pane=1
|
||||
[events] activated row0: replaying=1 pane=1 pane_w=240 tick=0 window=2.000 thumbnail=1 thumb_size=126x42 thumb_visible=1
|
||||
[events] replayed: channels=4 samples=20001 replaying=1 waveform=1
|
||||
[events] live again: replaying=0 waveform=0
|
||||
[events] recording off, trigger off: button_visible=0 pane=0 pane_w=0 tick=1 recording=0
|
||||
[events] recording off, trigger on: button_visible=0 pane=0 pane_w=0 tick=1 recording=0
|
||||
[events] recording off, trigger off: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
|
||||
[events] recording off, trigger on: button_visible=1 pane=1 pane_w=240 tick=1 recording=0
|
||||
[events] compare buttons: cached=2 selected=2 compare=1 accumulate=1
|
||||
[events] compare: kind=1 rows=1 channels=8 samples0=32964 traces0=4 traces1=-1 window=2.000 recording=1 pane=1
|
||||
[events] compare plots: area_b=1 rows_a=1 rows_b=1 list=4 x_a=[-2.000,0.000] x_b=[-2.000,0.000] same_x=1
|
||||
@@ -967,7 +1002,7 @@ the font and the display's scale factor; what it must never be is `0x0`.
|
||||
| `pane` | 1 once the history button has been clicked | the button's handler not revealing the pane |
|
||||
| `live` | `button_visible=0 pane=0** | a pane or a button on screen with no trigger mode: a column of nothing beside a live plot |
|
||||
| `after trigger on` | `button_visible=1 pane=0` | the pane revealed by trigger mode alone (it must be on demand from the toolbar button), or the button missing in trigger mode |
|
||||
| `recording off, trigger off` and `recording off, trigger on` | `button_visible=0 pane=0 recording=0` in both | the pane surviving its own switch being turned off -- the empty column the item exists to prevent -- or the button coming back with trigger mode while the cache is disabled |
|
||||
| `recording off, trigger off` and `recording off, trigger on` | `button_visible=1 pane=1 recording=0` in both **once the cache holds anything** (the probe's own run does by this point) | the button going away with the switch when events are already written -- an event on disk outlives the switch that wrote it -- or `pane_w` not being 240 with the pane open. With an *empty* cache the same two lines read `button_visible=0 pane=0`, which is the empty column the item exists to prevent |
|
||||
| `thumbnail`, `thumb_size`, `thumb_visible` | 1, a non-zero size, and 1 | the picture loaded through `gtk_picture_set_filename()`, which fails silently (the paint-able is there, the allocation is there, nothing is drawn), or a `GtkPicture` given a size request and no cap -- its natural size is its content, so the row's text is squeezed off. `thumb_size` is the reading that catches both |
|
||||
| `replaying` (last line) | 0 | a replay with no way back: *Live* must start a fresh client and thread |
|
||||
| `pane_w` | the divider's position: 0 while the pane is closed, its remembered width (240 by default) while it is open | the pane reserving a column while closed (a paned hands its start child its minimum unless the position says otherwise), or a width that is not remembered |
|
||||
@@ -979,3 +1014,67 @@ the font and the display's scale factor; what it must never be is `0x0`.
|
||||
| `compare sync` | `mirrored=1`, with `y_a` and `y_b` equal to the range the probe set on the first plot | the mirror not running, or running the wrong way: a y (or x span) change in one plot that the other never sees |
|
||||
| `accumulate` | `kind=2 rows=1 channels=8 samples0=33045 traces0=8 window=2.000` | the second event's layers not landing: `traces0` stays at the four signals the layout holds when the slots were not free, or the row named after a previous group's suffix so nothing binds |
|
||||
| `group left` | `replaying=0 kind=0 waveform=0` | the group surviving *Live*: a file view with no file behind it |
|
||||
|
||||
## `[boxzoom-probe]`
|
||||
|
||||
```
|
||||
[boxzoom-probe] open: swept=200x10 frame=100,0 200x853 axes_h=853 band=1 axis_before=[0.000000..2.000000]
|
||||
[boxzoom-probe] closed: frame=0x0 gone=1 axis_after=[0.000000..0.553167] zoomed=1
|
||||
```
|
||||
|
||||
`UDPSCOPE_PROBE_BOXZOOM_MS` injects a Ctrl box zoom straight into row 0's controller -- the way a
|
||||
headless probe has to, since the area's gestures arrive from GTK -- and reads the frame back off the
|
||||
*plot* one frame later. That is what makes this the adapter's per-frame sync being exercised: the
|
||||
library measures and draws the rect in tests of its own.
|
||||
|
||||
- `band=1` is the reading that matters. The pointer swept `200x10`, and the frame is `200` wide and
|
||||
`1665` -- the height of the axes -- tall. Ctrl zooms **x only**, so the band across the plot is what
|
||||
the release will apply, while the sliver the pointer actually swept is only the gesture. Its `x` and
|
||||
`y` must be the press point's.
|
||||
- `closed: frame=0x0 gone=1` is the other half of the contract: a finished gesture leaves no frame, so
|
||||
nothing goes on claiming a selection is open. Both lines together are the whole feature.
|
||||
- `axis_before`/`axis_after`/`zoomed` are the *settled* axis, read a few ticks after the release so
|
||||
that whatever pin or clamp the view has has already had its say -- which is the point: a rect can
|
||||
report the gesture and nothing about whether it did anything. **Run this in both modes and both must
|
||||
read `zoomed=1`.** A file opened with `--open` reading `zoomed=0` while the live view reads `1` is the
|
||||
defect this reading exists for, and it was invisible from the frame alone: the file view pinning its
|
||||
axis from the ring each frame is what swallowed the gesture.
|
||||
|
||||
## `[prefs-probe]`
|
||||
|
||||
```
|
||||
[prefs-probe] opened=1 apply=1 dir=/tmp/k5/cache/udpscope/events store=/tmp/k5/cache/udpscope/events/127.0.0.1-8000
|
||||
[prefs-probe] dir=/tmp/udpscope-prefs-events keep=7 rel=1 theme=dark live=1
|
||||
[prefs-probe] default restored: file=''
|
||||
[prefs-probe] browse: toplevels=3 extra=1 icon_ok=1 field='/tmp/k5/cache/udpscope/events'
|
||||
```
|
||||
|
||||
`UDPSCOPE_PROBE_PREFS_MS` opens the preferences dialog, fills its form, clicks **Apply** and reads
|
||||
the settings file *back from disk* -- not from `App::settings`, because a dialog that edits its own
|
||||
copy and never writes is exactly the failure this has to catch.
|
||||
|
||||
- `opened=1` is the window having come up; `apply=0` would mean its *Apply* button was never stored,
|
||||
which would make the click below test nothing.
|
||||
- `dir` is the directory the dialog *offers* -- the preference if there is one, otherwise the default
|
||||
written out in full (it is deliberately not a description: "the standard location" is not something a
|
||||
user can act on) -- and `store` is the directory the cache is really using. **`store` must be `dir`
|
||||
plus the source's folder name**, which is the reading that says the field shows the path in force
|
||||
rather than a plausible string. `dir` here is the default because the file had no preference yet.
|
||||
- The second line must echo the probe's own input -- directory `/tmp/udpscope-prefs-events`, keep
|
||||
`7`, relative time `1`, theme `dark`. Any field that differs means the form, the write
|
||||
or the parse dropped it.
|
||||
- `live=1` is the theme having reached the running style manager rather than only the file.
|
||||
- `browse: toplevels=N extra=1 field='…'` is the folder chooser: opened from its button, then cancelled
|
||||
from *outside* it. `extra=1` is the new toplevel that is the dialog -- a count, because the dialog is
|
||||
not named after anything a probe could match on -- and the field must still read the directory it read
|
||||
before it opened, because **a cancelled picker changes nothing**. This is the async-dialog crash
|
||||
surface (a callback landing after its dialog went away), which is why the preferences window is closed
|
||||
a beat later than the cancel and why the callback tolerates every widget being null.
|
||||
- `icon_ok=1` is the icon theme knowing `folder-open-symbolic`, the same question the toolbar-icons
|
||||
line asks of the bundled six. It is a reading rather than a convention because the button is an icon
|
||||
and *nothing else*: a name the theme does not carry draws a missing-image placeholder, and nothing
|
||||
else in a run would say so.
|
||||
- `default restored: file=''` closes the loop on the same idea from the other side: the probe types the
|
||||
default path back into the field and presses *Apply*, and the file must end up with **no**
|
||||
`event_cache_dir` at all. A path in the file is a user's choice, and typing the default is not one --
|
||||
pinning it there would stop the setting following the cache directory it is named after.
|
||||
Reference in new issue
Block a user