Files
udpscope/docs/claude/invariants-gtk-and-chrome.md
T
Martino FerrariandClaude Opus 4.6 5242babf99 docs: the signal badges, the signal pages and the six-badge overflow (step 2 of the redesign)
Also wrap the signals page's hint line, which at 375 px minimum widened the whole slide-over panel to 376.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-11-03 01:48:43 +01:00

63 KiB

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. 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 section of the sidebar's dock, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text.

  • Five 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}-symbolic.svg are the five 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.

  • 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 that pair: 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. A close-request handler's return value is load-bearing too, and its absence is silent: the signal is gboolean, TRUE means "handled" and suppresses the default handler that destroys the window, and G_CALLBACK is a cast — so a void lambda in the slot compiles without a warning and GTK reads whatever the compiler left in the return register as the verdict. The Configurations dialog (open_configurations()) was written that way, and the built binary returns non-zero from the lambda, so both its Close button and the window manager's close button emitted close-request, were told "handled", and the dialog stayed up — "the configurations dialog cannot be closed", which no reading of the callback's type can see. Every close-request handler therefore returns FALSE explicitly (the chooser's on_chooser_close(), the overview's on_window_close(), the math dialog's, and this one). 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 and the install command, the system-wide form first (cmake -DCMAKE_INSTALL_PREFIX=/usr .. && sudo cmake --build build --target install-help), the no-root install-help-user second with its caveat (see the location rule below) — 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.

    A user-local help set is one Yelp cannot draw the figures of, so the system-wide install is the one to recommend — and this is a location rule, not a markup or a file rule. Yelp sandboxes its WebKit subprocess, and that sandbox exposes the system help directories (/usr/share/help, /usr/local/share/help) but not $XDG_DATA_HOME/help: the pages load, their <media> figures do not, because each figure is a separate file the page references rather than something embedded in it. The symptoms read as anything but a location problem — opening a page as a file renders it with no images, and opening it through the app's help: URI leaves the viewer spinning for ever, the failed image fetch meaning the page's load event never fires while the sidebar title still updates, which looks like a navigation failure rather than a missing resource. A shipped GNOME page that carries a figure (/usr/share/help/C/accerciser/api_browser_plugin.page) draws either way, which is what isolates the cause to where the set lives. make install-help (into /usr/local) followed by yelp /usr/local/share/help/C/<id>/<topic>.page works; the same set under ~/.local/share/help does not. Two genuine defects were chased and fixed before the location was found — a <figure> with no <desc> and a bKGD chunk the PNG spec forbids on an alpha image — so do not read a figure that will not draw as a repeat of either, and do not revert those fixes: this is why both the failure alert in src/help_window.cpp and the manual's own overview.page name the system-wide install first and state the user-local one's caveat beside it.

  • 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, and re-syncs this dialog's own "(i)" rows through chooser_help_sync(); Guided tour starts a tour of this window -- the chooser's own walk -- and that walk's last stop 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 window's half instead of conflating a user's request with a first run. The chooser's half has its own entry points in source_dialog.cpp (chooser_tour_begin, chooser_tour_autostart), and that walk is what a first run meets first -- see the tour bullets in the help-affordance section below.

  • 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 one dock now, and has no GtkRevealer left in it: a single GtkScrolledWindow (dock_scroller) holding the cursors and the measurements one under another. It replaced an AdwViewSwitcher over an AdwViewStack, whose four text tabs did not fit 320 px (and a source can add more); a source kind's own panels are slide-over pages, not dock sections. The reason is the one this trap teaches: sections competing for one 320 px column each left every one of them short of room, and a scroller gives each its natural height. m scrolls the dock to the cursors (dock_show_section()); the signal list is not a dock section but the signals slide-over page, which g and the + badge open. 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 section scrolled out of view is still laid out, but ask about state, not about size still holds for anything a tick reads.

  • The dock is shown but insensitive until the stream has signals ("disable, do not hide"). update_side_visibility() and the tick's per-frame block set the column's visibility from the user's wish alone (sidebar_wanted) and the dock's sensitivity from have_signals; the toolbar's Dock button stays sensitive throughout. The wish is persisted (dock = true|false in the session file) and a change of it schedules a save. dock: in the dump reads the result.

  • The signal badge is the drag handle, and a drop handler only moves widgets. Dragging a signal onto a plot starts on its badge in the badge bar (a GtkDragSource on the badge's button, payload the signal index as a decimal string, taken by the plot area's existing drop target); the list's rows are not drag sources. A drag starting with the slide-over open would have part of the plot hidden under the panel, so the source's drag-begin closes it (which only restyles the badges: nothing is destroyed mid-drag). A click without movement never reaches the drag threshold and still opens the page; past it the source claims the sequence and the button's click is cancelled. Each badge is also a GtkDropTarget for reordering: on_badge_drop calls gtk_box_reorder_child_after and rewrites App::badge_order from the widgets, and never rebuilds the badges -- the dragged badge and the target whose drop emission is running must both survive it. A foreign drag whose string is not a signal index is refused. [sigbadges] reads the controllers, the payload and the reorder; pane drag: reads the close.

  • 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 switch 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-CRITICALs 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). 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 GtkWindows 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 AdwActionRows 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 "(i)" joins its surface's 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 a pencil in a corner. src/ui_context_help.cpp is a table of (id, title, body, shortcut, host) where the host is the row -- a panel's heading usually, and the row of a specific control (the "record events" switch, the pre-trigger share, the search block's expander header) where that is the thing being explained. The one exception is the plot, which has no heading: its "(i)" is an overlay child of the overlay the layout editor already uses, and its corner is free. The icon is dialog-information-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. math_head exists only while the math editor is open, so that row is skipped when it is null.
  • A GtkMenuButton ignores both min-width and a size request, and the icon is a node below its button subnode. The "(i)" is meant to be a mark, not a button, and the default is 34x34 (a 24 px floor plus 5 px of padding from Adwaita's button rule). Measured on libadwaita 1.10: setting min-width/min-height on the widget changed nothing, and neither did gtk_widget_set_size_request() -- the menu button's own measure wins. The node tree is menubutton > button > box > image (read off the CSS node dump), so install_css() reaches the glyph as menubutton.udpscope-hint > button > box > image. The direct-grandchild form > button > image matches nothing and is silent about it -- the glyph keeps its natural 16 px, which is why an earlier version shipped a 16x16 mark and took that for the floor it was not. Zeroing the outer node and the button subnode and asking the glyph for 12 px (min-width/min-height and -gtk-icon-size, the latter what actually scales the drawing rather than clamping its box) gives 12x12. (A class on the outer node alone leaves the subnode at 34; a class on the subnode alone shrank the width to 16 and left the height at 26.) There is no lower floor: the symbolic glyph renders at any request (measured down to 4 px), so 12 px is a legibility choice and not a limit. The dump's hint= reading is that number, measured with gtk_widget_compute_bounds() on the first visible hint -- a hint inside a collapsed GtkExpander's child is not in the widget-parent chain at all and reads 0x0, which would look like a mark shrunk to nothing. That is why the search block's hint rides the expander's header (a label-widget box, search_head in src/ui/trigger-panel.ui, which is in the tree) rather than its child, and why the [helpui] collapsed search: reading asserts the header hint is mapped and 12x12 while the block is collapsed.
  • 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 button itself is ui_impl::hint_button() reached either by the table's context_help_sync() or by context_help_button() for a window with no App (the source chooser) -- one button, two callers, so the two windows cannot drift into two affordances.
  • 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.
  • Click-away coexists with the plot's gestures because the observer never claims the sequence. The slide-over closes on a press anywhere outside it, and the plot underneath already owns pan, zoom, drop and right-click. A GtkGestureClick added to plot_overlay in GTK_PHASE_CAPTURE sees the press before the plot's own controllers and does not call gtk_gesture_set_state(), so the sequence stays unclaimed and the plot's gestures run exactly as before; claiming it (or putting a full-area target over the plot) would swallow the very pan the click was part of. The observer decides inside/outside by gtk_widget_compute_point() into the revealer plus gtk_widget_contains(), never by pick. The press that closes the panel still reaches the plot, which is accepted: it is one click and not a drag the user started on purpose. [slideover] reads click_inside_closes=0 click_outside_closes=1.
  • AdwToggleGroup is the segmented switch (libadwaita >= 1.7; the build asks for 1.8). It replaces the AdwViewSwitcher for a source with several panels (the E-1608: Analog out | Digital I/O). Its toggles are named by the slide-over page ids, and the group's notify::active-name is the input; writing it ourselves (slideover_open on a source page) goes through SlideOver::syncing, or the program's own write comes back as a click and re-opens the page it just opened. It is hidden unless a source page is open and there are two or more, which [badges] switch_visible= reads.
  • The tour is one implementation over two windows. ui_impl::TourView holds the overlay children and the walk's place in it (tour_build/tour_begin/tour_advance/tour_end/tour_update), and what differs between the windows is only the stops: a TourStop names a widget the owner resolves, so each window keeps its own table -- the main window's against App fields, source_dialog.cpp's kChooserStops against the Chooser's. A walk that passes its last stop calls the view's on_done; the window's passes null, the chooser's sets Config::tour_pending, which is how the two halves are one tour. App carries its own TourView (app.tour), so the machinery is compiled once and the probe reads either window's state by the same fields.
  • A stop declares the page it stands on, and the tour selects that page as the stop is entered -- by a hook in the main window and by a stack in the chooser. The main window has no page stack any more: its pages are the slide-over's. A TourStop::page there is a slide-over page id (trigger), and TourView::on_page(page_user, page) opens it -- or, for a stop whose page is null, closes the panel, so the card never points at the status line over an open panel. tour_end() calls on_page(nullptr) only if a walk was running (so Skip, Esc and the last Next close a panel the tour opened, while dismissing an idle tour does not close one the user opened). The chooser has no hook and keeps TourView::page_stack (an AdwViewStack, selected by name). select_view_page() is the single entry for both, and show_step() calls it before anything is measured. Two rules follow from a panel that slides. The page switch is not followed by a one-shot measure: tour_update(), which already runs every frame, fills the spotlight in on a later one, and for a stop with a page it waits for TourView::page_settled (the slide-over's gtk_revealer_get_child_revealed()) -- until then the target is moving, so the surface is dimmed with an empty hole. And the hole is the target's bounds clipped by every GtkScrolledWindow above it (tour_target_bounds()): a form taller than the panel's scroller overruns it, and an unclipped hole spotlights the badge bar and the status line under the panel. A target scrolled out entirely is a measured, empty rectangle (the function returns true), not a failure -- tour_update() zeroes the hole on it, because taking "false" for "not measured yet" keeps the previous frame's hole standing. A stack allocates a new page's widgets on the frame after the switch, so a measurement taken in the switch's own tick reads zero (the allocation bullet in invariants-trigger-and-pinning.md) -- reading the page name back is fine immediately, because that is state and not geometry.
  • The stop filter runs on get_mapped(), not is_visible(), and that is what separates a wrong page from a collapsed pane. GTK 4.24's gtk_widget_is_visible() walks only the widgets' own visible flags up the parents; it does not consult child_visible. Both a GtkStack's non-current pages and AdwOverlaySplitView's hidden sidebar are hidden with gtk_widget_set_child_visible(), so a widget on another page, and one in a collapsed pane, both read is_visible() == TRUE (verified against 4.24.1) -- the old filter kept a stop it meant to drop and left the spotlight on a target with no allocation. stop_is_walkable() keeps a stop when its target is mapped; a stop that names a page of a view with an on_page hook is always kept (the hook opens the page whatever the dock is doing, since the slide-over does not depend on it); and in a view with a page_stack a stop that names a page is kept when the stack is mapped -- a wrong page leaves the stack mapped, so the page switch reveals it, while a collapsed pane leaves it unmapped too and the stop is dropped. So the dock's stop is dropped in a window narrow enough to overlay it, and the main window's tour is five steps there ([toursurface] collapsed: ... steps=5: the signals stop names the signals slide-over page, which does not depend on the dock either). The filter lives in tour_begin(), not in each window's start_tour(), so the two windows cannot drift into two rules.
  • The chooser's tour needs an overlay over its whole window, and AdwWindow's content is replaced, not wrapped. Two of the chooser's stops name header-bar buttons, and an overlay inside the AdwToolbarView's content would give them negative coordinates and clamp the hole to the top edge. So the overlay becomes the window's content and adopts the toolbar view -- and the order matters: gtk_overlay_set_child() refuses a child that still has a parent, so adw_window_set_content(win, overlay) goes first (that call is what unparents the toolbar view) and gtk_overlay_set_child(overlay, content) second. The builder's object list still owns the view, so nothing is finalized in between. Getting the order wrong is a Gtk-CRITICAL and an empty chooser, which is exactly what the first version of this did.
  • The chooser's hints are its own table because it runs before App. An AdwEntryRow is not a box and its editor is internal, so a hint cannot join the host/port fields as a sibling -- appending it to the AdwPreferencesGroup would make it a row under the field rather than a mark beside it, so no hint is invented for them and the group's own description carries that text. The two rows the dialog does have -- the heading above the saved-source list and the waveforms page's action row -- carry chooser_sources and chooser_waveform, added with the same context_help_button() the window uses and re-synced by its Help menu's toggle.
  • 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 mapped 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. It starts in the source chooser when one is shown (the chooser is the surface a first run meets first, and the window's own auto-start is unreachable until the chooser has returned with a source) and in the window otherwise; either way it is a 1.5 s timer, so the window is up and laid out before anything points at it. The chooser's walk writes the key itself and hands the second half over by Config::tour_pending, so a first run is one tour that crosses the two windows.
  • 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() and get_mapped(), 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 widget on a closed slide-over page, trigger_* while the panel is shut, would read a bogus 630,46,0,0 without the mapped test), 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.