Two reports, both real, and neither was about the lock itself.
**A drag out of an overlaid sidebar had nowhere to land.** In a window too narrow to show both columns,
`AdwOverlaySplitView` puts the sidebar *on* the plot — that is what `collapsed` means — so the pointer
stays over the list and never over a drop target: a signal could be dragged but not dropped, which reads
as "dragging does not work". `row_drag_sidebar()` now hides the sidebar on `drag-begin` and restores it on
`drag-end`, and only when collapsed: side by side the plot is already visible, and hiding the list the drag
started in would be startling. The reading begins a drag through the *source* with the state forced
collapsed, the way the sidebar probe forces it:
pane drag: sidebar hidden for the drag=1 restored after it=1
**The compact view did not follow the trigger switch.** The locked line's first branch reads
`tc.enabled` — with trigger mode off it says `stream (trigger off)` and nothing else — and
`on_trigger_toggled()` never re-synced it, so the line kept saying whichever it had been. One call to
`sync_trigger_summary()` beside the switch sync. The dump's two `panes:` lines were already the test and
now pass it: with a session whose trigger is off, the line as found reads `stream (trigger off)` and the
live one reads the settings, because the exit probe turns the trigger on in between. Both reading `stream`
was the defect.
Verified: the two lines above; `panes: the same line as the probe found it: 'mode: stream (trigger off)'`
against the live `'signal: square | condition: window, rising | level: 0.3 .. 0.6 | mode: normal'`;
`make test-ui` 14 files; zero `Gtk-CRITICAL`. Docs: the `pane drag:` reading and the invariants bullet for
the overlay, and the `panes:` pair's second job — the refresh test — written down so the next reader knows
what both lines reading `stream` means.
448 lines
40 KiB
Markdown
448 lines
40 KiB
Markdown
# Invariants: GTK traps, the status bar, the icons and the help
|
|
|
|
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 the help pages.
|
|
|
|
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, the help window and the shortcut overview are opened by `show_about()`,
|
|
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is
|
|
exactly **one `GtkWindow`** among the three — the paged help window. 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 other two — the About dialog (an `AdwAboutDialog`)
|
|
and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**:
|
|
they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request`
|
|
on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the
|
|
pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for
|
|
two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is
|
|
the shape that prevents the double-open defect across all three.
|
|
- **`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 one `.txt`, one `<file>` line in the GResource manifest, and one `kPages`
|
|
row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its
|
|
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at
|
|
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from
|
|
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
|
|
A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources).
|
|
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
|
|
at all inside a box, so without the request it is allocated nothing and the page renders its text
|
|
with a silent gap where its screenshots should be — which is what the first version did.
|
|
- **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.
|