help: a tinier (i), hints beside their controls, and the tour starting in the chooser

The contextual help becomes the affordance the ask names: a plain (i)
(`dialog-information-symbolic`) at **16x16**, and one that sits beside the
control it explains rather than only in a pane's heading.

- The mark: `GtkMenuButton` ignores `min-width` on the widget and a size
  request, so the size is made in `install_css()` on all three nodes (outer
  `menubutton`, its `button` subnode, the glyph). Measured 16x16 against the
  default 34x34; the dump prints it as `hint=WxH`, measured with
  `gtk_widget_compute_bounds()` on the first *visible* hint.
- More sites: `events_record` (the "record events" switch in the trigger
  panel), `trigger_advanced` (the pre-trigger share row), `signals_config`
  (the signals pane's hint line, wrapped in a box so a hint can join it).
  Hints join as siblings, so none can cover a switch, pencil or dropdown.
- The source chooser gets its own layer -- `chooser_sources` (a new heading
  above the saved-source list) and `chooser_waveform` (the waveforms action
  row) -- built with the one `context_help_button()` both windows use and
  re-synced by its Help menu's toggle. The form's `AdwEntryRow`s cannot host a
  sibling hint, so none is invented for the host/port fields.
- The tour is one implementation over two windows: `ui_impl::TourView` plus a
  list of `TourStop`s; each window keeps its own table. A walk that passes its
  last stop calls `on_done`, and the chooser's sets `Config::tour_pending`, so
  a first run is one tour that crosses from the chooser into the app. The
  chooser's tour auto-starts from `settings.tour_seen` exactly as the window's
  does, and its "Guided tour" row now starts *it* instead of only requesting
  the window's.
- Probes: `UDPSCOPE_PROBE_CHOOSER_TOUR=1` / `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`,
  the hint size in the `help layer:`/`[helpui] contextual on:` lines, and the
  chooser's hints in its `chooser ui:` line. Docs updated in
  invariants-gtk-and-chrome.md, probe-readings.md and verification.md.
This commit is contained in:
Martino Ferrari committed 2026-10-14 22:42:38 +02:00
1 parent 395c9caec3
commit 829176248a
14 files changed
+911 -303

No files matched your search

+63 -15
View File
@@ -193,13 +193,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
and this dialog binds none, so a key printed here would be documentation that does nothing.
*Documentation* calls `show_help()` straight from the chooser (the manual is another process and
needs nothing of the app); *Enable contextual help* writes the settings file, which is what the app
reads when it starts a moment later; *Guided tour* cannot start anything — the tour is drawn on the
app's own window — so it sets **`Config::tour_pending`**, the one value `main()` owns and hands to
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
tour instead of conflating a user's request with a first run.
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
@@ -537,27 +540,69 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
stack, the visible child is the view, and `gtk_popover_present()` re-measures a popover that is
already up. `UDPSCOPE_PROBE_MENUS_MS` walks the *visible page*, which is the one line the stack cost
the probe.
- **A "?" joins its surface's heading row as a sibling, not as an overlay child.** An overlay child
sits *on* whatever is under it -- and every panel heading already has a switch or an [edit] button in
a corner. `src/ui_context_help.cpp` is a table of (id, title, body, shortcut, host) where the host is
the *heading box*, so a new surface is a row and nothing else. The one exception is the plot, which
has no heading: its "?" is an overlay child of the overlay the layout editor already uses, and its
corner is free. The icon is `dialog-question-symbolic` -- `help-about-symbolic` is *already* the
status bar's information button, and two "help" glyphs meaning different things on the same line is
the trap the seven app icons exist to avoid; the dump reads the name back through the theme for the
same reason the pane [edit] buttons do.
- **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) 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; its subnodes are where a small
one is made.** 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. Three selectors are needed, all in `install_css()`:
`menubutton.udpscope-hint` (the outer node), `menubutton.udpscope-hint > button` (its subnode) and
`... > button > image` (the glyph), with `min-width: 0; min-height: 0; padding: 0` -- and the result
is **16x16**, the glyph's own size, because a `min-width` is a floor and not a cap. (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.) The dump's `hint=` reading is that number, and it is measured with
`gtk_widget_compute_bounds()` on the first *visible* hint -- a hint inside a collapsed `GtkExpander`
is not in the widget-parent chain at all and reads 0x0, which would look like a mark shrunk to
nothing.
- **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.
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.
- **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.
- **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 visible* is the
@@ -567,8 +612,11 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
- **The first-run tour is a `settings.toml` key (`tour_seen`), written when the tour *starts*.** Not
"the settings file did not exist": a user who has already changed the theme has a file and has still
never seen the tour. Written on the way *in*, because a user who skips it in the first second has
still seen it. The auto-start is a 1.5 s timer from `on_activate()`, so the window is up and laid out
before anything points at it.
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