Files
udpscope/docs/claude/invariants-gtk-and-chrome.md
T
Martino Ferrari 395c9caec3 help: send readers to the install targets, in the alert and the manual
The failure alert told people to run `cmake --install build --prefix ~/.local`;
that still works, but `cmake --build build --target install-help-user` is the
form that needs no prefix guessing and prints the path it wrote, so the alert
names it and stays its former length. The manual gains a short "Installing
this manual" section on the overview page naming both targets -- the no-root
user one and the prefix-driven one for a system-wide or packaged install,
which the caller wraps in sudo -- and README and the GTK invariants doc follow
the alert's new command.
2026-10-14 22:30:07 +02:00

580 lines
52 KiB
Markdown

# Invariants: GTK traps, the status bar, the icons and the manual
The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and how the Mallard manual is installed and opened.
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
- **The status line's per-second fields are measured once a second, not per frame.**
`App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same
tick as the channel dump; the status line only formats what is already there. The
badge's markup is likewise rewritten on a change of phase (twice a second), never on
all sixty frames.
- **A `notify::<prop>` handler takes three parameters, a `value-changed` handler two.**
`GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while
`GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot
serve both: on the `notify::` connection the `GParamSpec*` lands in the `user_data` slot
and the handler dereferences it as its `App*`. `G_CALLBACK` is a cast, so nothing warns.
That is why the trigger panel's spin buttons and its dropdowns have separate thin entry
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
- **`MeasureDialog` and `MathDialog` are brace-initialised *positionally* in `src/ui.cpp`**, so a field
added in the middle of either silently shifts every argument after it: add at the end of the widget group
and read the initialiser in the same commit. A row array widened by a new control is the same trap from
the other side -- a `rows[N]` that was not grown shows a control the sync never touches, and the compiler
says so as an out-of-bounds warning on `rows`.
- **A control that cannot affect the current type is *hidden*, not disabled**: the trigger's band row is
hidden for `dropout`, the measurement's for every non-crossing type, and the node editor's median row for
every op but `de-noise`. A greyed row is a question the user can see and cannot answer.
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
back, that is a feedback loop: `sync_window_display()` writes a value rounded for reading
into the window entry and picks the unit for it, and the late notify had
`on_window_unit_changed()` re-parse that rounded text, turning a 500 s request into
499.98 s. The guard is `App::window_sync_unit` — the unit we last synced — and a selection
equal to it is ours, since GTK only notifies on a change. Any new control that both writes
and reads a `GtkDropDown` needs the same shape; the flag alone is not enough.
- **The header bar's buttons are icon-only**, so headless probes must reach them
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
`find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`,
`measures_toggle`, `math_toggle` -- packed with `header_bar_pack_end()`, where the **first** packed
is the rightmost, and the bar is an *ancestor* rather than the parent because packing parents into
an internal box), and the cursors pane owns its own
**Reset** button rather than the overflow menu owning one for it. The dump prints
`panel controls: … ; toolbar, left to right: …` to say where they are and in what order. The widget
tree's child order *is* the visual order left to right, so that line reads as the *reverse* of the
pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally
settled the pack-order rule above (two comments in `build_toolbar()` disagreed with each other and
one of them with reality): it is computed on the first *tick*, because the dump
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
how the first version of that line earned them. What stays in the menu is what acts on the *data*
(Fit, Export, Configurations) and the one editor that is not a pane (the layout editor): the signal list
is a *tab* of the sidebar, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text.
- **Seven of this app's icons are its own, and they are a `GResource` compiled into the
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
measurement ruler, and the stock names those buttons used to carry said something else
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,events,resize-rows,tab-measure,tab-signals}-symbolic.svg`
are the seven action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last
user; `udpscope-measure-symbolic` **stayed**, because the Preferences window's Plot page carries it --
an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is
the Preferences window's icon, not the tab's.
The two **tabs** got glyphs of their own, and each is a *pair* rather than one of the icons above.
`udpscope-tab-measure-symbolic` is two cursors beside the ruler they read against — each a cap over two
bold dashes, beside a ruler of two bands with three marks between them and **no end caps** (a closed
frame reads as a rectangle, and costs a fifth of the ink on two lines that say nothing) — and
`udpscope-tab-signals-symbolic` is a sine crossing its dashed level, beside two bold channel rows. They
are not reuses, because a glyph the toolbar already carries means two things the moment a tab shows it
too; instead the *trigger's* own icon (`udpscope-trigger-symbolic`) was redrawn to the Signals pill's
picture, so the pill and the button that turns the trigger on are recognisably about the same thing.
The wave is a **full period and not a hump**: a crest and a trough are what make it read as a wave
rather than as a hill, and the level's dashes survive between the crossings. (A hump was tried and
reverted — it named the "above the level" half of a trigger and lost the crossings, which is the half
that matters.)
Both are **32x16** composites drawn **bold** — one thick band, two thick dashes, few features — because
at this size a thin wave or a 1px tick reads as *texture* rather than as a shape, which is what "not
clear" looks like. Wider than tall on purpose: a 16px square cell carries one motif, not a pair. And
**GDK paints an icon into a square box whatever the document's aspect** (measured:
`gtk_icon_theme_lookup_icon()` at 32 reports `32x32`, and librsvg fits it to `32x16`), so the pair is
letterboxed rather than stretched — and the pill's image is given `pixel_size` 32 in `name_side_tabs()`,
which draws the artwork at the size it is written, so its bars stay crisp. Every glyph here is
**inset two pixels**, because art flush with the cell edge is what a button's padding or a crop takes a
pixel off, and a fractional scale is what would blur it, which is why nothing is drawn at 24 or 48. The
two tab glyphs measure 29% and 30% ink, inside the range the icons above them set.
- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has
none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them
with `gtk_accelerator_parse()` — so they are `"space"`, `"<Control>e"`, `"<Control>slash"`,
`"plus minus"` (a space-separated pair is the "either of these" form the label draws as
`+ / -`), never the prose `"Ctrl+E"` or `"+ / -"`. Neither GTK's syntax nor libadwaita's
knows a **wheel or a drag**: `"Wheel"` costs a `Failed to parse` warning and draws an empty
cell. Those six rows therefore carry `accel = ""` and put the prose in `subtitle`, which is
the one field that is free text. A row added with a parse error is otherwise silent — the
overview still opens and the row is simply blank — so read the probe run's stderr for
`Failed to parse` after touching either table.
- **The status bar is two tiers, and the dump reads the detail one.** `format_glance()` writes
the bar and `format_detail()` writes both the popover's prose and `App::status_detail`, which
is what `exit_probe_step3`/`exit_probe_step2` copy into `dbg_mode_live`/`dbg_mode_triggered`.
Those two used to read the *label*, and the label no longer carries `packets`, `gaps`,
`plot 1:` or the buffer figures — so a `gtk_label_get_text()` there would silently reduce
every documented `mode …:` assertion to four fields. The glance line's state field is **one
word from a fixed set** (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`,
`disarmed`) for the layout reason above; a word **wider than the widest already there**, or a
field whose width tracks the stream, breaks the row the plot is laid out under. The set may
grow — `connecting` was added for the no-CONFIG state — but only on that condition, and the
check is the dump's `max_glance_chars`. `connecting` is derived from `have_config` alone and
sits **ahead of every other branch**, including the display mode's: with no signal list, every
other word claims something that does not exist yet. The `mode` prose branches the same way for
the same reason, which is what keeps the two tiers from disagreeing.
- **The ⓘ popover's widgets are built once and only their text is set.** It is visible when the
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
the dump is the assertion.
- **The About dialog and the shortcut overview are opened by `show_about()` and `show_shortcuts()`,
and each closes with the matching call; the manual is *not* one of these.** `show_help()` hands
the `help:<document>` URI to the desktop help viewer (`GtkUriLauncher`, i.e. Yelp) — a process of
its own that cannot be closed from here — because the manual is a Mallard set installed for the
freedesktop `help:` URI, not a GResource in this binary (see the help bullet below). So there is
exactly **one `GtkWindow`** among what the app opens: the shortcut overview. It closes with
`gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope
pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the
math dialog fell into exactly this trap, and the pointer then points at a dead widget until the
next attempt to open the same window. The About dialog (an `AdwAboutDialog`) is an **`AdwDialog`
and not a `GtkWindow`**: it closes with `adw_dialog_close()` and clears on `::closed`, there being
no `::close-request` on an `AdwDialog`. It shares one `on_dialog_closed()` handler with the About
pointer, keyed by a **pointer to the pointer** it must null (`&g_about`), which keeps the handler
honest without knowing which dialog it is. One `GtkWidget*` (or `AdwDialog*`) per window, nulled
in its own close handler, is the shape that prevents the double-open defect. `close_help_windows()`
(misnamed now) closes just the two dialogs; a failed *launch* is reported by the launcher's own
callback with a `GtkAlertDialog`, so a click that cannot open the manual says so. That callback is
also `show_help()`'s **optional reporter** (`std::function<void(bool ok, const std::string& error)>`)
— the one hook that runs on the success path, where no window of ours is left to observe; the probe
uses it and nothing else does.
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at
configure time (so `add_executable` can name the file) and again on every build through
`udpscope_build_info` (so the commit and the timestamp belong to the binary that is running).
`git` absent leaves `kCommit` at `"unknown"` and the build succeeds. The About dialog and the
`[help]` probe both read `kVersion`, `kCommit` and `kTimestamp` from it; `settings=` in the
probe reads `config_dir()`, which is the path the session file is written to.
- **The keyboard zoom's modifiers are not the wheel's.** `+`/`-` with **Ctrl** restricts to x
and with **Alt** to y, while the wheel keeps upstream's Shift = x / Ctrl = y convention. The
two cannot agree: Shift is unusable on the keyboard path because `key_from()` folds `'+'` and
`'='` onto `kKeyZoomIn` — `+` *is* Shift+`=` on common layouts, so a bare zoom-in already
arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special
case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y`
tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`.
- **A help page is a Mallard file, and the manual is *not* in the binary.** `help/C/<id>/` holds
`index.page`, one `<topic>.page` per subject, and the `figures/*.png` the pages link, and the
whole set is installed (the `install()` rule in CMakeLists) to `<datadir>/help/C/<id>/` — the
path the freedesktop Help System Specification's `help:` URI resolves `help:<id>` to. The
document identifier is *derived from the desktop file's basename* (`org.udpscope.app.desktop.in`
→ `org.udpscope.app`), so it is the `.desktop` name, the GApplication id and the install
directory at once; `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that one
place. `show_help()` opens `help:<id>` with `GtkUriLauncher` (Yelp runs in its own process, so
nothing here holds a pointer to it), and its failure callback shows a `GtkAlertDialog`. That
failure is **careful about which thing is missing**: `gtk_uri_launcher_launch_finish()` reports a
launch that could not be handed over — the *handler's* absence — and cannot tell that from a
document that was never installed, so `help_installed_path()` walks the specification's own
`$XDG_DATA_HOME/help/<lang>/<id>/` then `$XDG_DATA_DIRS/help/<lang>/<id>/` for an `index.page` and
the dialog says which one it is: with the manual there, install a viewer (for example `yelp`); with
it absent, the identifier, the expected installed path and the install command
(`cmake --build build --target install-help-user`, which installs it under the user data
directory; `install-help` is the prefix-wide form for a system or packaged install) — and a
viewer as well, the second possibility the error alone cannot rule out. Reporting a missing
*document* as a missing *handler* is the defect that shape avoids.
`UDPSCOPE_PROBE_HELP_MS` launches it too, and its `[help] launch attempted= done= ok= error=''
installed=` line is the reading — `ok=1` is the URI handed to a viewer and not a window on screen,
since Yelp is another process and cannot map one headlessly; `installed=` sits beside it because a
failing `ok=0` has to be read against whether the document was there at all.
`UDPSCOPE_PROBE_HELP_MS` no longer parses pages — they are files, not markup in a resource — so
its `[help]` line asserts the set is *there*: `topics` (the `.page` files other than
`index.page`), `index`, and `figures`, scanned from `UDPSCOPE_HELP_DIR`. `topics=0 index=0` means
the manual is missing from the tree, which is the finding the old `parsed != pages` used to name.
`cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files:
`yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render.
`yelp-check validate` is deliberately absent — it fetches the Mallard RelaxNG schema over the
network and fails offline for that reason, not for a fault in the pages. The figures come from
`tools/make-help-shots.sh`, which drives the app headlessly against the mock and annotates a grab
(GTK4 must be forced to the Cairo renderer, `GSK_RENDERER=cairo`, or the X11 surface reads back
blank). Mallard is translatable with `itstool`, but there is no i18n set-up here: only the `C`
set is authored.
- **The chooser carries the same help affordances as the window, and the tour request crosses as a
value — not a global.** The source chooser runs *before* `App` exists, so a user who has not started
yet is the one least able to find the help; its header bar therefore has a help `GtkMenuButton`
(the `.ui` holds the button, code holds the rows) whose three entries are the window's Help view's
the same way — `ui_impl::menu_button()`/`menu_toggle()`, the app's own row shape — with the
`.udpscope-menu` class on the popover. That class's rules only exist once `install_css()` has run, so
`choose_source()` calls it before building the rows (`install_css()` is idempotent precisely because
it now has two callers; the app's `on_activate()` still calls it and is a no-op by then). The rows
show **no accelerators**: the window's Help view prints `F1`/`Ctrl+T` because the window binds them,
and this dialog binds none, so a key printed here would be documentation that does nothing.
*Documentation* calls `show_help()` straight from the chooser (the manual is another process and
needs nothing of the app); *Enable contextual help* writes the settings file, which is what the app
reads when it starts a moment later; *Guided tour* cannot start anything — the tour is drawn on the
app's own window — so it sets **`Config::tour_pending`**, the one value `main()` owns and hands to
both `choose_source()` and `run_ui()` (`open_waveform` and the preferences' relative time cross the
same way). `on_activate()` schedules `tour_start_requested()` 1.5 s later, the same delay the
first-run auto-start uses, and **that callback is separate from `tour_autostart()` on purpose**: it
sets `tour_requested`, not `tour_auto_started`, so `help layer:` says which of the two started the
tour instead of conflating a user's request with a first run.
- **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.**
Putting the events pane in a paned is what makes its width the user's to drag -- but with the
position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column
while it is *closed*, and the plot pays for a pane nobody opened. The position is the only knob that
says "no room": store the width when it is closed (reading `gtk_paned_get_position()` *before*
zeroing it) and set 0, restoring it when the pane opens. `gtk_paned_set_shrink_start_child(TRUE)` is
still needed, or the minimum would clamp even a zero position.
- **An unrevealed `GtkRevealer` measures 0x0 and is still given the leftover width.** `reveal_child
= FALSE` makes both the revealer's minimum and its natural size zero -- and its box then hands it
everything the expanding sibling beside it did not claim, so a *collapsed* pane reserved a
310-pixel empty column between the window edge and a plot drawn 630 pixels wide in a 1270-pixel
window. Every vertical revealer in the side panel hides this, because there the leftover goes down
instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not
take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane
*is* open. The allocated width is how this was found -- the revealer's own measure said 0.
**The sidebar is a tab stack now, and has no `GtkRevealer` left in it**: an `AdwViewSwitcher`
above an `AdwViewStack`, each page a `GtkScrolledWindow` of its own (the two pages are different
heights, so one shared scroller would leave a switch scrolled where the new page never asked to
be). The reason is the one this trap teaches: five sections competing for one 320 px column left
every one of them short of room. A *feature's* on/off is no longer a visibility at all -- the
**cursors'** toggle sets its pane's **body** insensitive -- the readout has nothing to show with the
instruments off -- and the *body* and not the pane, because the pane's heading carries its own switch
(see the next bullet). The **trigger's body is deliberately never disabled**: a trigger is configured
*before* it is switched on, so switching it off to change it, or to read what it is set to, is the
ordinary way to work. It used to go insensitive with the mode, which left the settings unreachable
exactly when a user wanted to change them; `trigger panel sensitive …=1` is the regression guard, and a
`0` there means the configuration has been locked out again. The
trap itself does not go away with the widget: a stack's hidden page measures 0x0 too and is still
given the leftover width, so **ask about state, not about size** carries over unchanged. Every
allocation read in the sidebar is a debug capture, and each reads 0 on the page that is *not*
current -- a change in what the reading means, not a defect, and no functional code may depend on
those numbers.
- **`AdwViewSwitcherPolicy.narrow` still draws a label; an *empty title* is what makes a pill an icon.**
Measured on libadwaita 1.10: with `narrow` and a title, the pill holds a `GtkImage` **and** a
`GtkLabel` — 48px of "Measure" inside a 54px pill — so the policy alone does not give an icon-only tab
bar. The sidebar's pages therefore have **no title** (their `name` is what identifies them to
`select_side_tab()` and the session file) and the pills are *named in code* instead: the first tick
calls `name_side_tabs()`, which walks the switcher's children — the pills are there in page order, and
`AdwViewSwitcherButton` is not a public type, so there is no type check to make — and sets each pill's
**tooltip and accessible name** from the page's name, then hides the empty label that would otherwise
leave the icon off-centre. An icon-only pill is still a named control for a screen reader, and
`switcher: … pills=2 labels=0 names=2` is the reading for all three facts at once.
- **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an
`AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the
list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides
the sidebar on `drag-begin` and restores it on `drag-end` — only when collapsed, because side by side the
plot is already visible and hiding the list the drag started in would be startling. `pane drag: sidebar
hidden for the drag=1 restored after it=1` is the reading; the state is forced collapsed the way the
sidebar probe forces it.
- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The two
panes' `[edit]` buttons are `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic`
(the check: done). The done one was `emblem-ok-symbolic`, which breeze ships and **Adwaita does not** —
so the button that leaves configuration mode was blank, which no other reading could see: the button
worked, the layout was unchanged, and only `pane edits:` (the two names and
`gtk_icon_theme_has_icon()`'s verdict, clicked into each mode) would say so. Check a name against
`/usr/share/icons/Adwaita` before using it; a name found under `breeze` is not an answer.
- **A pane's heading switch and the header bar's button are one control in two places.** Each pane's
heading (`build_trigger_panel()` builds the trigger's, `cursors-panel.ui` has the cursors' in its
`head`) carries a `GtkSwitch` that runs the *same action* the toolbar button runs, and
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change — so clicking
either end moves both, and `panel switches: trigger=1/1 cursors=1/1` is the reading. Two traps:
a `GtkSwitch` emits `notify::active` for a *programmatic* write as well, so the sync holds a guard
(`App::panel_switch_sync`) or the program's own write comes back as a click; and the switcher must
stay *out* of what the pane disables, because a control inside an insensitive parent cannot be
clicked — a switch that greyed out with its own pane could never turn it back on. That is why the
sensitive target is the pane's **body** (`trigger_body`, `cursors_body`) and not the pane.
- **A list box outlives the children of the pane it sits in, and the teardown order is not yours to
choose.** Tearing the window down finalizes the events pane's buttons, then the list box, and the
list's own `selected-rows-changed` then reaches a handler holding the freed pointers: four
`Gtk-CRITICAL`s from `gtk_widget_set_sensitive` and a read of memory nobody owns. `close-request` is
*not* the hook -- `UDPSCOPE_EXIT_AFTER_MS` ends the self-check with `gtk_window_destroy()`, which does
not emit it. The hook that does work is `App::window`: every teardown path clears it *before* the
widgets go (the exit probe says so in a comment: "stop the tick before the widgets go"), so a handler
that can fire during teardown checks that first, rather than asking GTK whether a pointer is still a
widget.
- **A `GtkPicture`'s natural size *is* its content, so a size request on it is a floor, not a cap.**
A 480x160 texture in a row of a list claims the row's whole width and squeezes the text beside it
out of the row -- the box gives a non-expanding child its natural size and grows it further when
the expanding child has nothing left to take. Give the picture a **frame** whose size request equals
the **scaled texture's** size (natural == minimum, which is what stops the box growing it), and
scale the texture once at decode: it is also what keeps a per-row cache at 12 KB per picture rather
than 300.
- **A pointer to a widget inside a list survives the list being rebuilt.** `events_thumb` named a row's
picture across `refresh_events_pane()`, which removes every row first: the next reader of that
pointer got a `Gtk-CRITICAL` from `gtk_picture_get_paintable` on freed memory (the four criticals
read as "the pane is empty"). A rebuild has to null what it is about to destroy, not only rebuild it.
- **`gtk_picture_set_filename()` can render nothing, silently.** It hands the file to the image
loader's sandboxed, asynchronous decoder, which on this machine fails without a message: the
picture keeps a *paint-able* and takes an allocation, so a probe that checks
`gtk_picture_get_paintable()` reads success while the pane shows an empty rectangle (the event
pane's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this
thread and `gtk_picture_set_paintable()` hands over the result -- and a NULL texture at least says
so. A picture that must be on screen is also a picture with a **height request**: `can_shrink`
alone asks for no height at all in a box.
- **A cache that is the *only* source of a widget's content must be created by the thing that reads
it.** The event pane's thumbnails come from an `EventThumbCache` hung off the list box with
`g_object_set_data_full`, and `event_preview_texture()` answers out of that cache or not at all.
Nothing ever created it — so for as long as the pane existed, **every** row came up without a
picture, which is what "event screenshot are unusable and sometime missing" was. A missing cache
there is not a slow path, it is a blank one; `event_thumb_cache()` therefore creates one on first
use. Owned by the **list**, because nothing else has the pane's lifetime: textures of a list that
no longer exists are so much held memory, and the destroy notify is where they are unref'd.
- **Hover magnification is a tooltip, not a popover of ours.** A row's thumbnail is 138 px wide and
the stored preview is 480: the small one says *which* event this is, and the zoom says what is in
it. GTK's own tooltip (`gtk_widget_set_has_tooltip` + `query-tooltip` + `gtk_tooltip_set_custom`)
is shown, placed, dismissed **and destroyed** by GTK, and takes no pointer grab. A popover per row
would be a parented widget to unparent on every rebuild of the pane — one defect below — and a
grab to lose, for a thing with no interaction in it. Two details: `can_shrink` must be FALSE on the
tooltip's picture or the tooltip is given the smallest size its content allows (the opposite of the
point), and the path handed to the handler has to be **owned by the closure**
(`g_signal_connect_data` with a destroy func), since the record it came from is a loop variable.
- **Unparenting a popover inside its own `closed` emission is a use-after-free.** Dismissals go
through `gtk_popover_popdown()`, which emits `closed`; a handler that unparents the popover there
drops the reference the parent held, the widget dies, and GTK's own popdown path then runs on freed
memory — `gtk_popover_get_autohide: assertion 'GTK_IS_POPOVER (popover)' failed`, on every menu
entry that dismisses its popover, which is every entry there is. `on_popover_closed()` therefore
defers the unparent by one idle and holds a reference across it
(`g_idle_add_full(..., g_object_ref(popover), g_object_unref)`), so the popover outlives the
emission that dismissed it.
- **`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 -- which is why it is on
every file in every bundle -- and that includes an XML rule easy to break by hand: **a comment may
not contain a double hyphen**, so prose written the way every C++ comment in this project is written
(` -- like this -- `) fails the build with a caret under the offending character. Use an em dash in a
UI file.
What it cannot catch is a file that is valid XML but wrong for GTK, and that is what
`cmake --build build --target test-ui` is for: it round-trips every file through
`blueprint-compiler`, which type-checks properties and child types against the GIR -- for *every*
file, rather than for the windows a probe happens to open (see
[build-and-run.md](build-and-run.md)). Without that check, `GtkBuilder` reports such a file with a
**`Gtk-ERROR`, which aborts the process** by default, so a window built from bad markup takes the
application down rather than failing to appear.
Two more 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*.
- **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.
- **An `AdwDialog` is not a `GtkWindow`, and not every toplevel is a widget either.** The preferences
are an `AdwDialog`, which is a `GObject` that a *window* presents, so it cannot parent a
`GtkFileDialog`: the form keeps the window it was opened with (`PrefsForm::parent`, the caller's) and
hands that to the picker. Two things follow, and each cost a run. In its floating presentation mode an
`AdwDialog` adds a **native surface** to `gtk_window_get_toplevels()`, so that list holds things that
are neither `GtkWindow`s nor widgets -- `GTK_WINDOW()` on one is a segfault inside `gtk_window_close()`,
and `g_object_unref()` on one is a `GLib-GObject-CRITICAL`. Anything walking it must test
`GTK_IS_WIDGET()` *and* `GTK_IS_WINDOW()` first, and a probe should not walk it at all where a
cancellable will do.
- **A folder chooser outlives the dialog that opened it unless something cancels it.** The picker's
answer writes a row, and the row is gone once the dialog closes. `PrefsForm::browse_cancel` is
cancelled by the `closed` handler and replaced by the next chooser: one open picker at a time, and none
that can call back into a cleared form. It is also how a probe takes the cancel path without a mouse.
- **An `AdwApplicationWindow` refuses a titlebar, and refuses it by *aborting*.** `gtk_window_set_titlebar()`
on one is a fatal `Adwaita-ERROR` (`is not supported for AdwApplicationWindow`), not a warning that
leaves the window merely ugly. The main window is therefore a `GtkApplicationWindow` -- an Adw window
would want its header as an `AdwToolbarView`'s top bar instead -- and `build_toolbar()`'s `GtkHeaderBar`
stays the window's own titlebar, which is also what gives the window its controls: a `GtkHeaderBar`
draws them only as a titlebar.
- **libadwaita's rows do not share one property set, and a `.ui` is where that shows.** An
`AdwEntryRow` is an `AdwPreferencesRow`: it has a `title` and **no `subtitle`**. `AdwSpinRow` and
`AdwSwitchRow` are `AdwActionRow`s and have both. A hint about an entry -- "empty = unicast" --
therefore belongs in the *group's* `description`, which is the better place for it anyway: a
placeholder vanishes as soon as there is a value, and a description does not. And an `AdwDialog` has
**no `content` property**: its child is a plain `<child>`, where an `AdwToolbarView` does take
`content`. Both mistakes are a `Gtk-ERROR` at load, so both abort the process, and both are caught by
the probe that opens every window rather than by anything at build time.
- **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.
- **A `_` in a label is a mnemonic only on a widget that was asked for one.** GTK reads the underscore as
"the next letter is this widget's accelerator, and draw it underlined", so wherever the widget has not
been asked, GTK draws the underscore *literally* -- which is what `_Directory`, `_Server`, `Keep _events`
and eleven more titles did in the two preference forms, for as long as they existed. Two different
switches, FALSE by default in both cases: `GtkLabel`/`GtkButton` take `use-underline`, and the rows take
**`AdwPreferencesRow:use-underline`** ("whether an embedded underline in the title indicates a mnemonic").
The row classes that carry it are not all related -- `AdwEntryRow` has its own, while
`AdwSpinRow`/`AdwComboRow`/`AdwSwitchRow` get it through `AdwActionRow` -- so `row_underlines()` asks by
name on a `GObject` (`g_object_class_find_property`, then `g_object_get`) instead of casting to one row
type: casting a row whose type is only forward-declared in that translation unit does not even compile.
`test-ui` is what catches the property being set on a class that does not have it -- it validates every
property against the GIR, inherited ones included.
- **A mnemonic is unique per window, and the first match wins.** `_Cancel` and `_Connect` both claimed C in
one form, so `Alt+C` could only ever reach one of them and the other's marker was unreachable -- worse
than no mnemonic, because the underscore is there in the source for a reader to trust. `Conn_ect` freed
it. Rows on different pages of a preferences dialog may share a letter: an unshown page cannot be
activated.
- **A `GtkExpander`'s child is not in the widget-parent chain.** `gtk_widget_get_parent()` on the widget an
expander holds answers NULL, before *and* after the window is presented, while `gtk_expander_get_child()`
returns it -- measured all three ways (the `.ui`'s `child` property, a code `gtk_expander_set_child()`, a
code-made expander). So a probe that walks parents to prove a widget is inside a panel reads 0 for
everything behind an expander, on a panel that is entirely correct; the walk has to start at
`gtk_expander_get_child()`. That mistake cost a false regression, a "the property does not attach it"
conclusion drawn from it, and a pointless second root in the file before the throwaway program settled it.
Related: `gtk_expander_set_child()` asserts that its child has no parent yet, so a block the `.ui` has
already put in the panel cannot then be handed to an expander -- the file's `child` property is the right
way, and it works.
- **`--` is not allowed inside an XML comment, and a `.ui` comment is XML.** `<!-- ... -- ... -->` is a parse
error: `xml-stripblanks` fails the build, and `make test-ui` prints `FAIL <file>` with **no** message under
it, because blueprint-compiler's decompiler aborts on the malformed XML before it can report anything --
the bare `FAIL` line is that mistake and nothing else. The house style for a dash in prose is the em dash,
which is why no other `.ui` in `src/ui/` contains a double hyphen. It has cost two slices a debugging
round for one character.
- **A `.ui` file instantiates its objects once, so anything whose *count* is data cannot live in one.** The
layout editor's overlay buttons are one per row, plus one per boundary between them: a `GtkBuilder` would
hand back the first and nothing for the rest, and there is no "prototype the code copies" in the format.
They are therefore code, and what they share -- the icon, the two CSS classes that make a small round
on-screen-display control, and the size -- is one function (`layout_overlay_button()`) rather than three
copies. The same reason keeps the math editor's palette and parameter rows in code: their count follows the
model, not the file. `.ui` files are for the frame, and the frame is what has no count.
- **A code-added child of a header bar lands *after* the file's, so going first means using a slot.**
`gtk_header_bar_pack_start()` appends to the start group and there is no prepend. The time-window control is
code -- its menu's rows come from the buffer's limits -- and belongs at the bar's head, so `header-bar.ui`
declares an empty `window_control_slot` box as its first start child and the code appends the control into
it. Packing the control straight onto the bar would have put it to the right of everything the file packed,
and `[udpscope] header:` (`window_button_w=130 bar_w=756`) is the reading that shows such a move.
- **`<child type="end">` is added leftwards-from-the-right, in document order.** A header bar's end group
reads the *reverse* of the file: `header-bar.ui` declares menu, sidebar, math, measures, cursors and the
screen shows cursors, measures, math, sidebar, menu. The three comments this replaced each had it wrong
once, which is why the order is the file's now and a byte-for-byte diff of the probe is the check.
- **A widget can end up in the wrong container, and only a *structural* read notices.** `math_body` was
written as a `<child>` of the *window* instead of the box above it when the math editor's frame moved into a
`.ui`. A `GtkWindow` takes exactly one default `<child>`, so nothing failed: the body -- with the canvas and
the parameter form inside it -- was left parentless, `visible` but never `mapped`, size `0x0`, drawing
nothing. The symptom was "the editor opens and the canvas is blank", with the window sized around its
remaining children. `check_ui` passes such a file; `xml-stripblanks` passes it; so does every probe that
reads *values* off the widgets inside. What catches it is a reading that walks *up* from the widget
(`in_dialog=1`) or asks whether it was ever given an allocation (`mapped=1`, `canvas=420x836`). When a frame
moves into a file, the probe that proves it has to read the frame's *structure*, not only the numbers it
holds.
- **A popover's autohide is only as good as its grab.** `GtkPopover` dismisses itself when it loses the
pointer grab -- and the grab is the thing that can be missing: a window manager moving it, a nested popover
(a `GtkDropDown`'s menu, which the time-window row has) taking it, and *every* headless run, where there is
no manager to hand one out and the probes have to turn autohide off before a popover will stay up to be
measured. The symptom is "sometimes it is awkward to close": a click anywhere else leaves the bubble up.
`on_window_popover_press()` states the rule outright instead -- any press that reaches the window's own
surface closes the window popover. That needs no hit test beyond the button, because a popover *is* its own
surface and a click inside it never arrives; the button is left to its own click, whose toggle is what
closes it. `[windowpop]` reads both halves, with autohide off so it is testing the rule and not the grab.
- **A notice shares the status line with the glance, so it has to *look* like a message.** The status bar
renders `app.notice + " | " + glance` in one label, which is exactly where a reader skimming the
routine text ("live · 9.97 kS/s · 2 s window") will not see it: a refused drop that said "plot 1 holds four
signals already" was read as no feedback at all. While a notice is fresh the label carries the stock
`.warning` class, so a message looks like a message and the class goes when the notice does. Two corollaries
worth knowing: the line composes on its own cadence, so a notice can appear a beat after it was set (do not
read the label in the same tick a handler ran), and a refusal should say what to *do* about it -- "drop it on
another plot, or add a row with the layout editor (L)" rather than the bare statement of the limit.
- **The status line cannot cover the gap before the window exists, so a window does.** Between the chooser
closing and `gtk_window_present()` there is no main loop at all: the chooser destroyed its own window,
then `main()` allocates the sample buffer, waits for the first CONFIG or reads a recording, and
`on_activate()` builds every panel -- at a few gigabytes of buffer that is the ten seconds a user reported
as "the dialog disappeared and nothing happened". `loading_show()`/`loading_hide()` (in
`source_dialog.cpp`, beside the chooser they continue) keep an `AdwWindow` on screen across it, raised in
`main()` **before** the model is constructed and taken down in `on_activate()` **after** the present --
either side is a frame of empty screen. Three rules it exists under:
- **It must be painted before the caller blocks.** `gtk_window_present()` only queues the frame, and
nothing after it runs a loop, so `loading_show()` ends in a nested `GMainLoop` quit by a timeout
(the chooser's own idiom). Iterating pending events is *not* enough: a window needs a frame-clock
cycle, and the frame clock only ticks while a loop runs.
- **It says what the wait is, in words.** The spinner is frozen for the whole of it -- no loop -- and a
progress bar would be a lie for the same reason, so the text carries the information: the address
being connected to or the file being opened, and under it the buffer size, which is the number that
explains the duration. It is `deletable=0` with no title buttons: a close button that cannot be
serviced until the work is over is worse than none.
- **Only the path that *asked* gets one.** `main()` raises it only when the chooser actually ran, which
is why every documented probe (all of them pass `--server`) is on exactly the path it was before --
and a `--server` run never had a window to lose, so the post-present notice is the right place for it.
`loading_hide()` is a no-op when none is up, so `run_ui()` needs no notion of which path it is on.
## The help affordance layer
The `Help` submenu, the contextual help's "?" and the guided tour, and the traps each of them met.
- **`gtk_widget_measure()` includes the widget's own margins.** Measured: a box whose natural width
is 280 and whose `margin-start` is 453 reports `natural = 733`. That matters wherever a measure is
used to *place* the widget and the margin *is* the placement: the width the widget was placed at
comes back as its width, and the card of the guided tour walked between the bottom-right corner and
the left of the target from frame to frame because of it (a `place_natural()` that set no size
request did not help -- the margin alone was the loop). Take the margins off a measure that feeds a
position back. `hdl`/`bd`-style CSS padding is *not* part of it, and neither is the other axis's
margin, so it is `margin-start + margin-end` on the horizontal and top/bottom on the vertical.
- **A widget's allocation is not `gtk_widget_get_width()`, and a measure is never a position.** GTK's
`gtk_widget_get_width()`/`get_height()` return the widget's **content** box: `size_allocate`
subtracts the CSS border and padding before storing them (measured on the tour card, which carries
`border 1px` + `padding 12px`: `get_width()` reads 254 where the rectangle on screen is 280), so a
card *placed* from it overhangs its own size and covers the target it is meant to sit beside.
`gtk_widget_compute_bounds()` is that rectangle -- border and padding in, margins out -- and is what
the probe's geometry and the help's figures already read. And `gtk_widget_measure()` cannot stand in
for it: its size includes the widget's margins (the bullet above), and asking for a **height with a
width** (`for_size`) on a widget whose margins were then subtracted from that width makes GTK warn
`Trying to measure GtkBox ... for width of N, but it needs at least M` **once per frame**, because
the for_size lands one margin short of the minimum the widget answers with. The guided tour's card
therefore takes its size from `compute_bounds()` once a frame has run and falls back to a measure
**with no for_size at all** (`-1`, the form GTK measures without that check) only on a frame with
nothing allocated yet. The warning reaches no probe -- GTK is built with **structured logging**, so
it goes through the log *writer* and never `g_log_set_handler()`/`g_log_set_default_handler()` --
which is why the exit dump counts GTK's own warnings through a writer function (`gtk warnings:`,
which must read 0) as the one reading that can assert the class away.
- **The overflow menu's `Help` submenu is a two-page `GtkStack`, and neither of the obvious
alternatives works.** Rebuilding the rows -- the plot menu's own shape -- destroys
`app.layout_toggle`, which points into the main view and which `sync_layout_editor()` and the `L`
accel read; swapping the *popover's child* instead leaves whichever view is not showing with the
popover as its only owner, so it is finalized the moment it is swapped out. The popover's child is a
stack, the visible child is the view, and `gtk_popover_present()` re-measures a popover that is
already up. `UDPSCOPE_PROBE_MENUS_MS` walks the *visible page*, which is the one line the stack cost
the probe.
- **A "?" joins its surface's heading row as a sibling, not as an overlay child.** An overlay child
sits *on* whatever is under it -- and every panel heading already has a switch or an [edit] button in
a corner. `src/ui_context_help.cpp` is a table of (id, title, body, shortcut, host) where the host is
the *heading box*, so a new surface is a row and nothing else. The one exception is the plot, which
has no heading: its "?" is an overlay child of the overlay the layout editor already uses, and its
corner is free. The icon is `dialog-question-symbolic` -- `help-about-symbolic` is *already* the
status bar's information button, and two "help" glyphs meaning different things on the same line is
the trap the seven app icons exist to avoid; the dump reads the name back through the theme for the
same reason the pane [edit] buttons do.
- **Off is gone, not greyed, and the buttons are found by a data key rather than kept in a list.** The
list is what the math editor cannot survive: its heading lives in a builder that is dropped and
rebuilt on every opening, so a stored pointer into it would go stale one dialog later. A key on the
button (`udpscope-help-hint`, carrying the surface's id) is both the mark that says "this is a hint"
and the name the probe and the dump report it by, and removing a hint is `gtk_widget_unparent()`,
which means the same thing for a box and for the plot's overlay.
- **The guided tour takes no grab, and that is the whole reason it is an overlay.** Four dim
rectangles around the hole are ordinary widgets that swallow the clicks that land on them and
nothing else; `Skip`, `Finish` and `Escape` hide them, and there is no popover, no gesture and no
grab to leak. The probe asserts it as a reading -- `controllers=` against the count taken before the
tour opened -- rather than as a claim, because "no lingering grab" is exactly the sort of thing a
later change can add back without noticing.
- **The hole is kept from the last frame that could measure the target.** `gtk_widget_compute_bounds()`
answers nothing on a frame that lands between two layout passes, and letting the hole empty there
made the spotlight and the card jump to the corner and back; a *target that is not visible* is the
different case (a genuinely absent surface) and is what empties it. The placement is also
re-measured on **every frame**, never once at the start: a widget's allocation is 0 on the frame it
is first shown, and a window resize moves it under a one-shot measurement.
- **The first-run tour is a `settings.toml` key (`tour_seen`), written when the tour *starts*.** Not
"the settings file did not exist": a user who has already changed the theme has a file and has still
never seen the tour. Written on the way *in*, because a user who skips it in the first second has
still seen it. The auto-start is a 1.5 s timer from `on_activate()`, so the window is up and laid out
before anything points at it.
- **The `help figures:` line is captured on the tick, like every other size the dump prints.** It names
ten surfaces' window-coordinate rectangles for the help's annotated figures, and the dump runs after
the window is destroyed -- a `gtk_widget_compute_bounds()` there is the "no `gtk_widget_*` call in the
dump" rule again. `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger
summary while the pane is in configuration mode -- keeps its own flag true and reads 0x0, and a crop
box drawn from that is a box over the wrong thing. Its numbers depend on the *window's* size, which
under the documented `xvfb-run` is the window's 756x894 minimum (a 640x480 screen), so a run on a
larger screen gives different rectangles for the same panels.