# 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::` 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. - **`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) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text. - **Six 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,math}-symbolic.svg` are the four action icons; beside them in `src/icons/scalable/apps/` are the two **application** icons, `org.udpscope.app.svg` (128px colour artwork) and `org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an application's own icon and is where the **in-process** window icon resolves from; it is also the layout that `cmake/install_desktop_entry.cmake` mirrors when it copies the same two files into `$XDG_DATA_HOME/icons/hicolor/scalable/apps` and `.../symbolic/apps` — those installed copies are what makes `Icon=org.udpscope.app` in the desktop entry resolve (a desktop shell cannot read a GResource compiled into the binary). All six are bundled by `src/icons/udpscope.gresource.xml` (CMake runs `glib-compile-resources`) and reached through **one** `gtk_icon_theme_add_resource_path()` in `run_ui()`, right after `adw_init()` — not at the top of `build_toolbar()`, where it used to be: there are two consumers now (the window's own icon, set before the toolbar is built, and the toolbar's buttons) and `run_ui()` is the one place ahead of both. `adw_init()` calls `gtk_init()`, which is what makes `gdk_display_get_default()` valid there. A resource rather than files beside the binary because udpscope is run straight out of the build tree and has no install step. Five authoring rules, each of which fails quietly: * **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the colour written in the file and stays dark on a dark theme. **In a symbolic icon everything is therefore a filled `path`** — including anything that wants to look stroked, which is drawn as a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on purpose: `org.udpscope.app.svg` is 128px colour artwork that is never recoloured, so it strokes its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the other. * **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the first draft placed features on half-pixels and the whole icon was a grey smear at 1x. * `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in. * Overlapping subpaths under the default nonzero rule must be **wound the same way** or they cancel instead of uniting. All of them run clockwise. * **A malformed SVG is a build error, and that is the only place it can be caught.** Every `` in `src/icons/udpscope.gresource.xml` carries `preprocess="xml-stripblanks"`, which makes `glib-compile-resources` pipe it through `xmllint` — so `--` inside an XML comment, which is illegal and which librsvg refuses the whole file for, fails the build with a file, a line and a caret under the offending character (`…app-symbolic.svg:5: parser error : Double hyphen within comment`). It has to be here because **no runtime check can see it**: librsvg refuses the file but the icon *name* still resolves, so `gtk_icon_theme_has_icon()` returns true and the icon draws nothing. `gtk_icon_theme_lookup_icon()` cannot tell either (see the `app icons:` line under Verification), `GMarkup` accepts the double hyphen because it is a lenient subset parser, and `gdk_pixbuf` goes through glycin here, which sandboxes a subprocess per load and refuses this project's own icons outright. The cost is that the `xmllint` binary is a build dependency; it ships with libxml2, which GTK already requires. `gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see `toolbar icons:` in [probe-readings.md](probe-readings.md)): a resource path that was never added does not raise an error, it draws `image-missing`. - **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"`, `"e"`, `"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 `` 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. - **`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.