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>
692 lines
63 KiB
Markdown
692 lines
63 KiB
Markdown
# Invariants: GTK traps, the status bar, the icons and the manual
|
|
|
|
The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and how the Mallard manual is installed and opened.
|
|
|
|
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
|
|
|
- **The status line's per-second fields are measured once a second, not per frame.**
|
|
`App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same
|
|
tick as the channel dump; the status line only formats what is already there. The
|
|
badge's markup is likewise rewritten on a change of phase (twice a second), never on
|
|
all sixty frames.
|
|
- **A `notify::<prop>` handler takes three parameters, a `value-changed` handler two.**
|
|
`GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while
|
|
`GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot
|
|
serve both: on the `notify::` connection the `GParamSpec*` lands in the `user_data` slot
|
|
and the handler dereferences it as its `App*`. `G_CALLBACK` is a cast, so nothing warns.
|
|
That is why the trigger panel's spin buttons and its dropdowns have separate thin entry
|
|
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
|
|
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
|
|
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
|
|
- **`MeasureDialog` and `MathDialog` are brace-initialised *positionally* in `src/ui.cpp`**, so a field
|
|
added in the middle of either silently shifts every argument after it: add at the end of the widget group
|
|
and read the initialiser in the same commit. A row array widened by a new control is the same trap from
|
|
the other side -- a `rows[N]` that was not grown shows a control the sync never touches, and the compiler
|
|
says so as an out-of-bounds warning on `rows`.
|
|
- **A control that cannot affect the current type is *hidden*, not disabled**: the trigger's band row is
|
|
hidden for `dropout`, the measurement's for every non-crossing type, and the node editor's median row for
|
|
every op but `de-noise`. A greyed row is a question the user can see and cannot answer.
|
|
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
|
|
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
|
|
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
|
|
back, that is a feedback loop: `sync_window_display()` writes a value rounded for reading
|
|
into the window entry and picks the unit for it, and the late notify had
|
|
`on_window_unit_changed()` re-parse that rounded text, turning a 500 s request into
|
|
499.98 s. The guard is `App::window_sync_unit` — the unit we last synced — and a selection
|
|
equal to it is ours, since GTK only notifies on a change. Any new control that both writes
|
|
and reads a `GtkDropDown` needs the same shape; the flag alone is not enough.
|
|
- **The header bar's buttons are icon-only**, so headless probes must reach them
|
|
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
|
|
`find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`,
|
|
`measures_toggle`, `math_toggle` -- packed with `header_bar_pack_end()`, where the **first** packed
|
|
is the rightmost, and the bar is an *ancestor* rather than the parent because packing parents into
|
|
an internal box), and the cursors pane owns its own
|
|
**Reset** button rather than the overflow menu owning one for it. The dump prints
|
|
`panel controls: … ; toolbar, left to right: …` to say where they are and in what order. The widget
|
|
tree's child order *is* the visual order left to right, so that line reads as the *reverse* of the
|
|
pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally
|
|
settled the pack-order rule above (two comments in `build_toolbar()` disagreed with each other and
|
|
one of them with reality): it is computed on the first *tick*, because the dump
|
|
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
|
|
how the first version of that line earned them. What stays in the menu is what acts on the *data*
|
|
(Fit, Export, Configurations) and the one editor that is not a pane (the layout editor): the signal list
|
|
is a 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-CRITICAL`s from `gtk_widget_set_sensitive` and a read of memory nobody owns. `close-request` is
|
|
*not* the hook -- `UDPSCOPE_EXIT_AFTER_MS` ends the self-check with `gtk_window_destroy()`, which does
|
|
not emit it. The hook that does work is `App::window`: every teardown path clears it *before* the
|
|
widgets go (the exit probe says so in a comment: "stop the tick before the widgets go"), so a handler
|
|
that can fire during teardown checks that first, rather than asking GTK whether a pointer is still a
|
|
widget.
|
|
- **A `GtkPicture`'s natural size *is* its content, so a size request on it is a floor, not a cap.**
|
|
A 480x160 texture in a row of a list claims the row's whole width and squeezes the text beside it
|
|
out of the row -- the box gives a non-expanding child its natural size and grows it further when
|
|
the expanding child has nothing left to take. Give the picture a **frame** whose size request equals
|
|
the **scaled texture's** size (natural == minimum, which is what stops the box growing it), and
|
|
scale the texture once at decode: it is also what keeps a per-row cache at 12 KB per picture rather
|
|
than 300.
|
|
- **A pointer to a widget inside a list survives the list being rebuilt.** `events_thumb` named a row's
|
|
picture across `refresh_events_pane()`, which removes every row first: the next reader of that
|
|
pointer got a `Gtk-CRITICAL` from `gtk_picture_get_paintable` on freed memory (the four criticals
|
|
read as "the pane is empty"). A rebuild has to null what it is about to destroy, not only rebuild it.
|
|
- **`gtk_picture_set_filename()` can render nothing, silently.** It hands the file to the image
|
|
loader's sandboxed, asynchronous decoder, which on this machine fails without a message: the
|
|
picture keeps a *paint-able* and takes an allocation, so a probe that checks
|
|
`gtk_picture_get_paintable()` reads success while the pane shows an empty rectangle (the event
|
|
pane's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this
|
|
thread and `gtk_picture_set_paintable()` hands over the result -- and a NULL texture at least says
|
|
so. A picture that must be on screen is also a picture with a **height request**: `can_shrink`
|
|
alone asks for no height at all in a box.
|
|
- **A cache that is the *only* source of a widget's content must be created by the thing that reads
|
|
it.** The event pane's thumbnails come from an `EventThumbCache` hung off the list box with
|
|
`g_object_set_data_full`, and `event_preview_texture()` answers out of that cache or not at all.
|
|
Nothing ever created it — so for as long as the pane existed, **every** row came up without a
|
|
picture, which is what "event screenshot are unusable and sometime missing" was. A missing cache
|
|
there is not a slow path, it is a blank one; `event_thumb_cache()` therefore creates one on first
|
|
use. Owned by the **list**, because nothing else has the pane's lifetime: textures of a list that
|
|
no longer exists are so much held memory, and the destroy notify is where they are unref'd.
|
|
- **Hover magnification is a tooltip, not a popover of ours.** A row's thumbnail is 138 px wide and
|
|
the stored preview is 480: the small one says *which* event this is, and the zoom says what is in
|
|
it. GTK's own tooltip (`gtk_widget_set_has_tooltip` + `query-tooltip` + `gtk_tooltip_set_custom`)
|
|
is shown, placed, dismissed **and destroyed** by GTK, and takes no pointer grab. A popover per row
|
|
would be a parented widget to unparent on every rebuild of the pane — one defect below — and a
|
|
grab to lose, for a thing with no interaction in it. Two details: `can_shrink` must be FALSE on the
|
|
tooltip's picture or the tooltip is given the smallest size its content allows (the opposite of the
|
|
point), and the path handed to the handler has to be **owned by the closure**
|
|
(`g_signal_connect_data` with a destroy func), since the record it came from is a loop variable.
|
|
- **Unparenting a popover inside its own `closed` emission is a use-after-free.** Dismissals go
|
|
through `gtk_popover_popdown()`, which emits `closed`; a handler that unparents the popover there
|
|
drops the reference the parent held, the widget dies, and GTK's own popdown path then runs on freed
|
|
memory — `gtk_popover_get_autohide: assertion 'GTK_IS_POPOVER (popover)' failed`, on every menu
|
|
entry that dismisses its popover, which is every entry there is. `on_popover_closed()` therefore
|
|
defers the unparent by one idle and holds a reference across it
|
|
(`g_idle_add_full(..., g_object_ref(popover), g_object_unref)`), so the popover outlives the
|
|
emission that dismissed it.
|
|
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
|
|
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
|
|
Caps Lock is on.
|
|
- **A UI file is data, and a mistake in it is a runtime abort, not a build failure.** `.ui` files live
|
|
in `src/ui/` and are compiled into the binary by the third GResource (`udpscope-ui.gresource.xml`,
|
|
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`.
|
|
|
|
`preprocess="xml-stripblanks"` catches a *malformed* file at build time -- which is why it is on
|
|
every file in every bundle -- and that includes an XML rule easy to break by hand: **a comment may
|
|
not contain a double hyphen**, so prose written the way every C++ comment in this project is written
|
|
(` -- like this -- `) fails the build with a caret under the offending character. Use an em dash in a
|
|
UI file.
|
|
|
|
What it cannot catch is a file that is valid XML but wrong for GTK, and that is what
|
|
`cmake --build build --target test-ui` is for: it round-trips every file through
|
|
`blueprint-compiler`, which type-checks properties and child types against the GIR -- for *every*
|
|
file, rather than for the windows a probe happens to open (see
|
|
[build-and-run.md](build-and-run.md)). Without that check, `GtkBuilder` reports such a file with a
|
|
**`Gtk-ERROR`, which aborts the process** by default, so a window built from bad markup takes the
|
|
application down rather than failing to appear.
|
|
|
|
Two more paid for: a `GIcon` property takes an **object**, not a name
|
|
(`<property name="icon">foo</property>` is read as a reference to another object and fails with
|
|
"Object with ID foo not found" -- the icon is a nested `GThemedIcon`); and a `.ui` big enough to
|
|
exceed 4095 characters needs `-Wno-overlength-strings` on its generated source, because
|
|
glib-compile-resources writes each file as one C string literal and the warning fires on generated
|
|
*data*.
|
|
- **Deprecated widgets may be the right widget, as long as no deprecated *function* is called.** The
|
|
shortcut overview is a `GtkShortcutsWindow` (`gtk/deprecated/`, deprecated in 4.20) because libadwaita's
|
|
successor, `AdwShortcutsDialog`, cannot draw a mouse gesture: `AdwShortcutsItem` has no icon property at
|
|
all, so its shortcut cell can only be an accelerator, and its cell reads "No Shortcut" when there is
|
|
none. `GtkShortcutsShortcut` has one generic gesture type whose `icon` *is* drawn. Loading the window
|
|
from a `.ui` and presenting it as a `GtkWindow` names no deprecated function, so the build stays at its
|
|
four warnings. When such a window closes, its builder is **replaced on the next opening**, not kept: the
|
|
window belongs to the builder's object list and a destroyed `GtkWindow` cannot be presented again.
|
|
- **An `AdwDialog` is not a `GtkWindow`, and not every toplevel is a widget either.** The preferences
|
|
are an `AdwDialog`, which is a `GObject` that a *window* presents, so it cannot parent a
|
|
`GtkFileDialog`: the form keeps the window it was opened with (`PrefsForm::parent`, the caller's) and
|
|
hands that to the picker. Two things follow, and each cost a run. In its floating presentation mode an
|
|
`AdwDialog` adds a **native surface** to `gtk_window_get_toplevels()`, so that list holds things that
|
|
are neither `GtkWindow`s nor widgets -- `GTK_WINDOW()` on one is a segfault inside `gtk_window_close()`,
|
|
and `g_object_unref()` on one is a `GLib-GObject-CRITICAL`. Anything walking it must test
|
|
`GTK_IS_WIDGET()` *and* `GTK_IS_WINDOW()` first, and a probe should not walk it at all where a
|
|
cancellable will do.
|
|
- **A folder chooser outlives the dialog that opened it unless something cancels it.** The picker's
|
|
answer writes a row, and the row is gone once the dialog closes. `PrefsForm::browse_cancel` is
|
|
cancelled by the `closed` handler and replaced by the next chooser: one open picker at a time, and none
|
|
that can call back into a cleared form. It is also how a probe takes the cancel path without a mouse.
|
|
- **An `AdwApplicationWindow` refuses a titlebar, and refuses it by *aborting*.** `gtk_window_set_titlebar()`
|
|
on one is a fatal `Adwaita-ERROR` (`is not supported for AdwApplicationWindow`), not a warning that
|
|
leaves the window merely ugly. The main window is therefore a `GtkApplicationWindow` -- an Adw window
|
|
would want its header as an `AdwToolbarView`'s top bar instead -- and `build_toolbar()`'s `GtkHeaderBar`
|
|
stays the window's own titlebar, which is also what gives the window its controls: a `GtkHeaderBar`
|
|
draws them only as a titlebar.
|
|
- **libadwaita's rows do not share one property set, and a `.ui` is where that shows.** An
|
|
`AdwEntryRow` is an `AdwPreferencesRow`: it has a `title` and **no `subtitle`**. `AdwSpinRow` and
|
|
`AdwSwitchRow` are `AdwActionRow`s and have both. A hint about an entry -- "empty = unicast" --
|
|
therefore belongs in the *group's* `description`, which is the better place for it anyway: a
|
|
placeholder vanishes as soon as there is a value, and a description does not. And an `AdwDialog` has
|
|
**no `content` property**: its child is a plain `<child>`, where an `AdwToolbarView` does take
|
|
`content`. Both mistakes are a `Gtk-ERROR` at load, so both abort the process, and both are caught by
|
|
the probe that opens every window rather than by anything at build time.
|
|
- **A dialog reachable from two windows cannot own its state in either.** The preferences open from
|
|
the main window's menu *and* from the source chooser, which exists before `App` does (`main()`
|
|
chooses a source, then builds the model and the UI). The widgets therefore live in a `PrefsForm` the
|
|
caller owns — `App::prefs`, `Chooser::prefs` — and what a change *means* is a callback, because it
|
|
means different things on the two sides. Both are plain members of their owners, on the same stack
|
|
frame, so nothing can outlive the frame that built it.
|
|
- **A transient window is destroyed before its parent.** `chooser_finish()` destroys the preferences
|
|
window before the chooser, and nulls the pointer *before* the destroy so `close-request` cannot
|
|
re-enter a dead window. GTK does not promise to take a transient down with its parent, and a window
|
|
whose handlers point into a returned stack frame is how this project already earned one
|
|
use-after-free.
|
|
|
|
- **A `_` in a label is a mnemonic only on a widget that was asked for one.** GTK reads the underscore as
|
|
"the next letter is this widget's accelerator, and draw it underlined", so wherever the widget has not
|
|
been asked, GTK draws the underscore *literally* -- which is what `_Directory`, `_Server`, `Keep _events`
|
|
and eleven more titles did in the two preference forms, for as long as they existed. Two different
|
|
switches, FALSE by default in both cases: `GtkLabel`/`GtkButton` take `use-underline`, and the rows take
|
|
**`AdwPreferencesRow:use-underline`** ("whether an embedded underline in the title indicates a mnemonic").
|
|
The row classes that carry it are not all related -- `AdwEntryRow` has its own, while
|
|
`AdwSpinRow`/`AdwComboRow`/`AdwSwitchRow` get it through `AdwActionRow` -- so `row_underlines()` asks by
|
|
name on a `GObject` (`g_object_class_find_property`, then `g_object_get`) instead of casting to one row
|
|
type: casting a row whose type is only forward-declared in that translation unit does not even compile.
|
|
`test-ui` is what catches the property being set on a class that does not have it -- it validates every
|
|
property against the GIR, inherited ones included.
|
|
- **A mnemonic is unique per window, and the first match wins.** `_Cancel` and `_Connect` both claimed C in
|
|
one form, so `Alt+C` could only ever reach one of them and the other's marker was unreachable -- worse
|
|
than no mnemonic, because the underscore is there in the source for a reader to trust. `Conn_ect` freed
|
|
it. Rows on different pages of a preferences dialog may share a letter: an unshown page cannot be
|
|
activated.
|
|
|
|
- **A `GtkExpander`'s child is not in the widget-parent chain.** `gtk_widget_get_parent()` on the widget an
|
|
expander holds answers NULL, before *and* after the window is presented, while `gtk_expander_get_child()`
|
|
returns it -- measured all three ways (the `.ui`'s `child` property, a code `gtk_expander_set_child()`, a
|
|
code-made expander). So a probe that walks parents to prove a widget is inside a panel reads 0 for
|
|
everything behind an expander, on a panel that is entirely correct; the walk has to start at
|
|
`gtk_expander_get_child()`. That mistake cost a false regression, a "the property does not attach it"
|
|
conclusion drawn from it, and a pointless second root in the file before the throwaway program settled it.
|
|
Related: `gtk_expander_set_child()` asserts that its child has no parent yet, so a block the `.ui` has
|
|
already put in the panel cannot then be handed to an expander -- the file's `child` property is the right
|
|
way, and it works.
|
|
|
|
- **`--` is not allowed inside an XML comment, and a `.ui` comment is XML.** `<!-- ... -- ... -->` is a parse
|
|
error: `xml-stripblanks` fails the build, and `make test-ui` prints `FAIL <file>` with **no** message under
|
|
it, because blueprint-compiler's decompiler aborts on the malformed XML before it can report anything --
|
|
the bare `FAIL` line is that mistake and nothing else. The house style for a dash in prose is the em dash,
|
|
which is why no other `.ui` in `src/ui/` contains a double hyphen. It has cost two slices a debugging
|
|
round for one character.
|
|
|
|
- **A `.ui` file instantiates its objects once, so anything whose *count* is data cannot live in one.** The
|
|
layout editor's overlay buttons are one per row, plus one per boundary between them: a `GtkBuilder` would
|
|
hand back the first and nothing for the rest, and there is no "prototype the code copies" in the format.
|
|
They are therefore code, and what they share -- the icon, the two CSS classes that make a small round
|
|
on-screen-display control, and the size -- is one function (`layout_overlay_button()`) rather than three
|
|
copies. The same reason keeps the math editor's palette and parameter rows in code: their count follows the
|
|
model, not the file. `.ui` files are for the frame, and the frame is what has no count.
|
|
|
|
- **A code-added child of a header bar lands *after* the file's, so going first means using a slot.**
|
|
`gtk_header_bar_pack_start()` appends to the start group and there is no prepend. The time-window control is
|
|
code -- its menu's rows come from the buffer's limits -- and belongs at the bar's head, so `header-bar.ui`
|
|
declares an empty `window_control_slot` box as its first start child and the code appends the control into
|
|
it. Packing the control straight onto the bar would have put it to the right of everything the file packed,
|
|
and `[udpscope] header:` (`window_button_w=130 bar_w=756`) is the reading that shows such a move.
|
|
- **`<child type="end">` is added leftwards-from-the-right, in document order.** A header bar's end group
|
|
reads the *reverse* of the file: `header-bar.ui` declares menu, sidebar, math, measures, cursors and the
|
|
screen shows cursors, measures, math, sidebar, menu. The three comments this replaced each had it wrong
|
|
once, which is why the order is the file's now and a byte-for-byte diff of the probe is the check.
|
|
|
|
- **A widget can end up in the wrong container, and only a *structural* read notices.** `math_body` was
|
|
written as a `<child>` of the *window* instead of the box above it when the math editor's frame moved into a
|
|
`.ui`. A `GtkWindow` takes exactly one default `<child>`, so nothing failed: the body -- with the canvas and
|
|
the parameter form inside it -- was left parentless, `visible` but never `mapped`, size `0x0`, drawing
|
|
nothing. The symptom was "the editor opens and the canvas is blank", with the window sized around its
|
|
remaining children. `check_ui` passes such a file; `xml-stripblanks` passes it; so does every probe that
|
|
reads *values* off the widgets inside. What catches it is a reading that walks *up* from the widget
|
|
(`in_dialog=1`) or asks whether it was ever given an allocation (`mapped=1`, `canvas=420x836`). When a frame
|
|
moves into a file, the probe that proves it has to read the frame's *structure*, not only the numbers it
|
|
holds.
|
|
|
|
- **A popover's autohide is only as good as its grab.** `GtkPopover` dismisses itself when it loses the
|
|
pointer grab -- and the grab is the thing that can be missing: a window manager moving it, a nested popover
|
|
(a `GtkDropDown`'s menu, which the time-window row has) taking it, and *every* headless run, where there is
|
|
no manager to hand one out and the probes have to turn autohide off before a popover will stay up to be
|
|
measured. The symptom is "sometimes it is awkward to close": a click anywhere else leaves the bubble up.
|
|
`on_window_popover_press()` states the rule outright instead -- any press that reaches the window's own
|
|
surface closes the window popover. That needs no hit test beyond the button, because a popover *is* its own
|
|
surface and a click inside it never arrives; the button is left to its own click, whose toggle is what
|
|
closes it. `[windowpop]` reads both halves, with autohide off so it is testing the rule and not the grab.
|
|
|
|
- **A notice shares the status line with the glance, so it has to *look* like a message.** The status bar
|
|
renders `app.notice + " | " + glance` in one label, which is exactly where a reader skimming the
|
|
routine text ("live · 9.97 kS/s · 2 s window") will not see it: a refused drop that said "plot 1 holds four
|
|
signals already" was read as no feedback at all. While a notice is fresh the label carries the stock
|
|
`.warning` class, so a message looks like a message and the class goes when the notice does. Two corollaries
|
|
worth knowing: the line composes on its own cadence, so a notice can appear a beat after it was set (do not
|
|
read the label in the same tick a handler ran), and a refusal should say what to *do* about it -- "drop it on
|
|
another plot, or add a row with the layout editor (L)" rather than the bare statement of the limit.
|
|
|
|
- **The status line cannot cover the gap before the window exists, so a window does.** Between the chooser
|
|
closing and `gtk_window_present()` there is no main loop at all: the chooser destroyed its own window,
|
|
then `main()` allocates the sample buffer, waits for the first CONFIG or reads a recording, and
|
|
`on_activate()` builds every panel -- at a few gigabytes of buffer that is the ten seconds a user reported
|
|
as "the dialog disappeared and nothing happened". `loading_show()`/`loading_hide()` (in
|
|
`source_dialog.cpp`, beside the chooser they continue) keep an `AdwWindow` on screen across it, raised in
|
|
`main()` **before** the model is constructed and taken down in `on_activate()` **after** the present --
|
|
either side is a frame of empty screen. Three rules it exists under:
|
|
- **It must be painted before the caller blocks.** `gtk_window_present()` only queues the frame, and
|
|
nothing after it runs a loop, so `loading_show()` ends in a nested `GMainLoop` quit by a timeout
|
|
(the chooser's own idiom). Iterating pending events is *not* enough: a window needs a frame-clock
|
|
cycle, and the frame clock only ticks while a loop runs.
|
|
- **It says what the wait is, in words.** The spinner is frozen for the whole of it -- no loop -- and a
|
|
progress bar would be a lie for the same reason, so the text carries the information: the address
|
|
being connected to or the file being opened, and under it the buffer size, which is the number that
|
|
explains the duration. It is `deletable=0` with no title buttons: a close button that cannot be
|
|
serviced until the work is over is worse than none.
|
|
- **Only the path that *asked* gets one.** `main()` raises it only when the chooser actually ran, which
|
|
is why every documented probe (all of them pass `--server`) is on exactly the path it was before --
|
|
and a `--server` run never had a window to lose, so the post-present notice is the right place for it.
|
|
`loading_hide()` is a no-op when none is up, so `run_ui()` needs no notion of which path it is on.
|
|
|
|
## The help affordance layer
|
|
|
|
The `Help` submenu, the contextual help's "?" and the guided tour, and the traps each of them met.
|
|
|
|
- **`gtk_widget_measure()` includes the widget's own margins.** Measured: a box whose natural width
|
|
is 280 and whose `margin-start` is 453 reports `natural = 733`. That matters wherever a measure is
|
|
used to *place* the widget and the margin *is* the placement: the width the widget was placed at
|
|
comes back as its width, and the card of the guided tour walked between the bottom-right corner and
|
|
the left of the target from frame to frame because of it (a `place_natural()` that set no size
|
|
request did not help -- the margin alone was the loop). Take the margins off a measure that feeds a
|
|
position back. `hdl`/`bd`-style CSS padding is *not* part of it, and neither is the other axis's
|
|
margin, so it is `margin-start + margin-end` on the horizontal and top/bottom on the vertical.
|
|
- **A widget's allocation is not `gtk_widget_get_width()`, and a measure is never a position.** GTK's
|
|
`gtk_widget_get_width()`/`get_height()` return the widget's **content** box: `size_allocate`
|
|
subtracts the CSS border and padding before storing them (measured on the tour card, which carries
|
|
`border 1px` + `padding 12px`: `get_width()` reads 254 where the rectangle on screen is 280), so a
|
|
card *placed* from it overhangs its own size and covers the target it is meant to sit beside.
|
|
`gtk_widget_compute_bounds()` is that rectangle -- border and padding in, margins out -- and is what
|
|
the probe's geometry and the help's figures already read. And `gtk_widget_measure()` cannot stand in
|
|
for it: its size includes the widget's margins (the bullet above), and asking for a **height with a
|
|
width** (`for_size`) on a widget whose margins were then subtracted from that width makes GTK warn
|
|
`Trying to measure GtkBox ... for width of N, but it needs at least M` **once per frame**, because
|
|
the for_size lands one margin short of the minimum the widget answers with. The guided tour's card
|
|
therefore takes its size from `compute_bounds()` once a frame has run and falls back to a measure
|
|
**with no for_size at all** (`-1`, the form GTK measures without that check) only on a frame with
|
|
nothing allocated yet. The warning reaches no probe -- GTK is built with **structured logging**, so
|
|
it goes through the log *writer* and never `g_log_set_handler()`/`g_log_set_default_handler()` --
|
|
which is why the exit dump counts GTK's own warnings through a writer function (`gtk warnings:`,
|
|
which must read 0) as the one reading that can assert the class away.
|
|
- **The overflow menu's `Help` submenu is a two-page `GtkStack`, and neither of the obvious
|
|
alternatives works.** Rebuilding the rows -- the plot menu's own shape -- destroys
|
|
`app.layout_toggle`, which points into the main view and which `sync_layout_editor()` and the `L`
|
|
accel read; swapping the *popover's child* instead leaves whichever view is not showing with the
|
|
popover as its only owner, so it is finalized the moment it is swapped out. The popover's child is a
|
|
stack, the visible child is the view, and `gtk_popover_present()` re-measures a popover that is
|
|
already up. `UDPSCOPE_PROBE_MENUS_MS` walks the *visible page*, which is the one line the stack cost
|
|
the probe.
|
|
- **A "(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](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.
|