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

+10 -1
View File
@@ -128,7 +128,16 @@ because a tick a hair under a rung's top rounds to 1000 at three significant fig
`%.3g` writes that `1e+03`, which is how a one-second tick read `1e+3ms`. All four carry
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero` and
`format_time_never_lets_a_field_reach_its_radix`. The minor
`format_time_never_lets_a_field_reach_its_radix`. The box-zoom selection was the one gesture with no
feedback at all: the controller tracked the rect and nothing drew it. The drawing went upstream with
the rest — `Plot::set_selection_rect()` (the plot carries a rect the host hands it and learns nothing
about pointers), `Controller::selection_preview()` (the mask decides which axis is *not* zoomed, and
the preview is widened along it, because the swept sliver of a horizontal Ctrl drag is the gesture and
the band across the plot is what the release will apply) and `Renderer::draw_selection_frame()` — and
it carries three tests: `a_ctrl_box_zoom_previews_the_band_it_will_zoom`,
`the_selection_frame_is_carried_and_taken_off` and `the_box_zoom_frame_lands_exactly_where_it_says`,
the last of which reads the headless framebuffer back and compares the same pixels with and without
the frame. The minor
grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is
0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
+12
View File
@@ -218,6 +218,18 @@ come back with a different answer.
Relative time's `slot_base_` also survives: it is samples-since-the-stream-began, the user's
chosen origin, and must not jump backwards because the link blipped.
## There may be no store at all
`rebuild_locked()` calls `store_.reset()` for a stream with **no channels** — the model's state until the
first CONFIG arrives — so `store_` is legitimately null, and `ScopeModel::live()` dereferences it. Every
path reachable without a stream has to ask first: **pausing a scope that is not connected** is one (the
button works, and freezing a view means taking a window from the rings), and it crashed inside
`live_window_locked()` on a null store. The guards belong at the entry points the UI can reach
(`live_window_locked()`, `publish_pause_view()`), not in `live()`, which cannot express "nothing there"
through a reference. The paths that only run for a *bound* trace — the series' gather, the SlotSeries'
`shared_channel()` — are safe by construction: a slot with no channel binds no series at all.
`UDPSCOPE_PROBE_PAUSE_MS` against a port nothing is listening on is the check.
## A waveform is a source that never arrives
`--open FILE` (and the chooser's Waveforms page) puts an exported `.npy` into the model instead of a
+32
View File
@@ -210,3 +210,35 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
Caps Lock is on.
- **A UI file is data, and a mistake in it is a runtime abort, not a build failure.** `.ui` files live in
`src/ui/` and are compiled into the binary by the third GResource (`udpscope-ui.gresource.xml`,
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`. `preprocess="xml-stripblanks"`
catches a *malformed* file at build time -- and nothing else does, which is why it is on every file in
every bundle. What it cannot catch is a file that is valid XML but wrong for GTK: `GtkBuilder` reports
that with a **`Gtk-ERROR`, which aborts the process** by default, so a window built by hand from bad
markup takes the application down rather than failing to appear. The `[help]` probe opening every
window is what catches that. Two specifics already paid for: a `GIcon` property takes an **object**, not
a name (`<property name="icon">foo</property>` is read as a reference to another object and fails with
"Object with ID foo not found" -- the icon is a nested `GThemedIcon`), and a `.ui` big enough to exceed
4095 characters needs `-Wno-overlength-strings` on its generated source, because glib-compile-resources
writes each file as one C string literal and the warning fires on generated *data* (the icon and help
bundles are small enough per file never to need it).
- **Deprecated widgets may be the right widget, as long as no deprecated *function* is called.** The
shortcut overview is a `GtkShortcutsWindow` (`gtk/deprecated/`, deprecated in 4.20) because libadwaita's
successor, `AdwShortcutsDialog`, cannot draw a mouse gesture: `AdwShortcutsItem` has no icon property at
all, so its shortcut cell can only be an accelerator, and its cell reads "No Shortcut" when there is
none. `GtkShortcutsShortcut` has one generic gesture type whose `icon` *is* drawn. Loading the window
from a `.ui` and presenting it as a `GtkWindow` names no deprecated function, so the build stays at its
four warnings. When such a window closes, its builder is **replaced on the next opening**, not kept: the
window belongs to the builder's object list and a destroyed `GtkWindow` cannot be presented again.
- **A dialog reachable from two windows cannot own its state in either.** The preferences open from
the main window's menu *and* from the source chooser, which exists before `App` does (`main()`
chooses a source, then builds the model and the UI). The widgets therefore live in a `PrefsForm` the
caller owns — `App::prefs`, `Chooser::prefs` — and what a change *means* is a callback, because it
means different things on the two sides. Both are plain members of their owners, on the same stack
frame, so nothing can outlive the frame that built it.
- **A transient window is destroyed before its parent.** `chooser_finish()` destroys the preferences
window before the chooser, and nulls the pointer *before* the destroy so `close-request` cannot
re-enter a dead window. GTK does not promise to take a transient down with its parent, and a window
whose handlers point into a returned stack frame is how this project already earned one
use-after-free.
@@ -75,3 +75,9 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed
range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so
the two cannot drift apart.
- **Relative time is decided by the newest statement, not by the default.** `Config::relative_time_specified`
is set by `--relative-time` and by the preferences — which the source chooser can reach before the run
is planned, so that is the one place a preference still changes *this* run. Both the list's own copy
(`apply_source()`) and a source's stored configuration (`apply_stored_settings()`) only fill in when
nothing newer was said. Without the marker, clicking a source row after applying the preference
silently reverted it — `[sources] picked ... relative=0`, with the dialog having just written `1`.
@@ -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
+104 -5
View File
@@ -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.
+5 -3
View File
@@ -301,9 +301,11 @@ as distinct from the fault injection below, which exercises ingest:
`[events] activated row0: replaying=1 thumbnail=1 thumb_size=108x36 thumb_visible=1` (the size is
what says the picture is *on screen*: a paint-able alone is not), `[events] replayed: channels=4
samples=20001 replaying=1 waveform=1` and `[events] live again: replaying=0 waveform=0`. It ends on
the pane's other precondition, through the trigger panel's switch: `[events] recording off, trigger
off` and `[events] recording off, trigger on` must **both** read `button_visible=0 pane=0
recording=0` -- the pane goes with its switch, in either trigger state. **`samples` must equal the
the pane's availability, through the trigger panel's switch: `[events] recording off, trigger off` and
`[events] recording off, trigger on` -- once the cache holds events (the probe's own run has written
some by this point) both read `button_visible=1 pane=1 recording=0`, because a button that shows the
cache must not go away with the switch that wrote it; against an *empty* cache the same two lines read
`button_visible=0 pane=0`, which is the empty column the item exists to prevent. **`samples` must equal the
export's row count for the same window** (`20001` for a 2 s event at 10 kS/s), `thumb_bytes` must be
a real BMP (`54 + 480*160*3`-ish, i.e. hundreds of kB, not 0), `thumbnail` must be 1 (the row loaded
the picture) and the last line must read `replaying=0` -- a replay with no way back is a one-way
@@ -0,0 +1,60 @@
# UI files and current GTK practice
Where the application's interface should be a `.ui` resource instead of widget construction, what is
already there, and the order to finish it in. Written 2026-10-11.
## Why
Three reasons, in the order they bite.
1. **Some things can only be said in a UI file.** The shortcut overview is the proof: GTK's
`GtkShortcutsShortcut` has exactly one gesture type that draws an `icon`, and libadwaita's
`AdwShortcutsItem` has no icon property at all -- so a mouse gesture can only be shown as a glyph
from a `.ui`. `src/ui/shortcuts.ui` is that, and it is the reason the file exists.
2. **Structure is data.** A window's arrangement rewritten in C++ is a hundred lines of
`gtk_box_append` that no one can read at a glance, cannot be compared with a diff, and cannot be
previewed. A UI file is the same arrangement in the notation the GNOME ecosystem reads.
3. **Current GTK and libadwaita have better widgets than this app uses.** The forms here are
`GtkEntry`/`GtkSpinButton` in hand-built label rows. The modern equivalents -- `AdwEntryRow`,
`AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow`, inside `AdwPreferencesPage`/`AdwPreferencesGroup` in an
`AdwPreferencesDialog` -- are what the preferences dialog should be, and they come from the same
move.
## Conventions (established, and in `docs/claude/invariants-gtk-and-chrome.md`)
- `.ui` files live in `src/ui/` and are compiled by the third GResource (`udpscope-ui.gresource.xml`,
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`.
- Every file carries `preprocess="xml-stripblanks"`, which is the only thing that catches a malformed
file at build time.
- A file that is valid XML but wrong for GTK **aborts the process** (`Gtk-ERROR`). The probe that opens
every window is what catches that; there is no build-time substitute.
- A `GIcon` property takes a nested object, not a name.
- A `.ui` over 4095 characters needs `-Wno-overlength-strings` on its generated source.
- A window built from a builder is replaced (not reused) on the next opening: it belongs to the
builder's object list, and a destroyed `GtkWindow` cannot be presented again.
- A deprecated *widget* is acceptable when no deprecated *function* is called.
## Acceptance for every slice
The same as any other change here: the build stays at **four warnings**, a run stays at **no
`Gtk-CRITICAL`**, and the slice's own surface is read back by a probe rather than asserted. A new `.ui`
needs a probe that proves it *loads* and that its contents match whatever drives it -- `[help]`'s
`ui=` against `tables=` is the model.
## Already done
- `src/ui/shortcuts.ui` -- the shortcut overview, with the mouse rows drawn as an icon cell.
- The `src/ui/` GResource and its build block.
- `[help]`'s `ui=`/`ui_accel=`/`ui_icon=`/`ui_bare=` reading, which checks the file against the tables
and asserts that no row is left without a visual.
## Remaining, smallest first
| where | what | why it is the right next step |
|---|---|---|
| `src/ui_prefs.cpp` (~330 lines) | the preferences: `.ui` + `AdwPreferencesDialog`, `AdwEntryRow`, `AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow` | the most visible win per line: it replaces four hand-built rows with four rows that look and behave like every other GNOME application's, and the file is self-contained |
| `src/help_window.cpp` (351 lines) | the help window's chrome as a `.ui` (the pages themselves stay resources) | small, self-contained, and its probe already opens it |
| `src/source_dialog.cpp` (~900 lines) | the source chooser and the host form: `.ui` + `AdwDialog`, with `AdwEntryRow`/`AdwSpinRow` for the form | the form is the second-biggest hand-built surface, and its probes drive every branch of it |
| `src/ui.cpp` (5774 lines) | the main window's **skeleton** only -- the `AdwApplicationWindow`, the header bar, the plot area's placeholder, the panes' containers, the status bar -- with the custom widgets, popovers and dialogs left in code | one file, one window: converting the skeleton is reviewable, converting the whole file is not. Do it in slices (header bar, then the panes, then the trigger panel, then the left panes) with the probe suite after each |
Nothing in this list should be done in one pass. Each row is its own change, verified on its own.