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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user