The tour measured its card with a height-for-width request whose for_size came from the natural width minus the margins the tour sets for positioning. A measure reports margins *included*, so that for_size landed a margin short of the minimum the box answers with and GTK warned once per frame: Trying to measure GtkBox 0x... for width of N, but it needs at least M (about 9/s for as long as the tour was up). The card now takes its size from gtk_widget_compute_bounds() once a frame has run -- its allocation rectangle, not gtk_widget_get_width(), which is only the content box -- and falls back to a measure with no for_size at all only on a frame with nothing allocated. GTK is built with structured logging, so its messages reach the log writer and never the handler API; no existing probe can read one. The exit dump now counts them (`gtk warnings:`, must read 0) through a writer function chained to g_log_writer_default(), which leaves stderr unchanged.
579 lines
52 KiB
Markdown
579 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 --install build --prefix ~/.local`, which writes `~/.local/share/help/C/<id>/`) — 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.
|