feat: the help is a submenu, with a contextual-help layer and a guided tour

The hamburger menu's help rows were three flat entries and a window. They are a **`Help` submenu**
now, which is where every GNOME menu keeps them, with the four things a first run cannot find:

  - Keyboard shortcuts   Ctrl+/   (the existing overview, `show_shortcuts()`)
  - Documentation        F1      (the existing manual, `show_help()`)
  - Guided tour          Ctrl+T  (new)
  - Enable contextual help        (new, a stateful row: its check *is* the setting)

The submenu *replaces* the menu's rows rather than floating beside them, the same shape and the same
reason as the plot's right-click menu: a submenu torn out of the menu it belongs to is a second thing
to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the moment anything
else closed its popover. It is the pages of a `GtkStack`, not a rebuild of the box and not a swap of
the popover's child: the main view holds `app.layout_toggle`, which the tick's `sync_layout_editor()`
and the `L` accel read, so rebuilding would leave that pointer on a destroyed widget, and a swapped
child would leave the view that is not showing without an owner.

**The contextual help is one table and a "?" per surface** (`src/ui_context_help.cpp`): the plot, the
trigger, the cursors, the measurements, the math channels, the signals, the event history, the time
window, the status line, and the math editor's identity row. Each row names the *heading box* its
button joins -- a sibling of the title, never an overlay child on a corner, which is what would cover
the switch or the [edit] button already there -- so adding a surface is a row and a widget pointer.
Off means *gone*, not greyed: the buttons are removed, and they are found by a data key rather than a
list of pointers because the math editor's heading is built afresh on every opening and a list would
go stale one dialog later. `contextual_help` and `tour_seen` are keys of `settings.toml`, written
through the same `Settings`/`load_settings()`/`save_settings()` the Preferences dialog uses, whose own
rows start from `load_settings()` and so carry them through untouched.

**The guided tour is five steps on a `GtkOverlay`** above the window's content: the plot, the side
panel, the trigger, the signals and the status line. There is no popover in it and therefore no pointer
grab to leak -- four dim rectangles around the hole are what make it modal -- and the probe asserts
that as a reading (`controllers=` against the count taken before it opened) rather than as a claim.
Everything is placed from `gtk_widget_compute_bounds()` re-read on every frame, because 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 hole is kept from the last frame that could measure the target, because a frame that
lands between two layout passes would otherwise empty it and make the spotlight jump. `Skip`, `Finish`
and `Escape` all hide every child and leave nothing behind.

A first run gets the tour once, decided by `tour_seen` and not by "the settings file did not exist" (a
user who changed the theme has a file and has still never seen it), and the flag is written when the
tour *starts* rather than when it finishes: a user who skips it in the first second has still seen it.

`UDPSCOPE_PROBE_HELPUI_MS` drives all of it -- the auto-start, the menu's seven rows and the
submenu's five, the toggle's effect read as a count of affordances on either side of it, and the tour
started, stepped forwards and back and dismissed -- and the exit dump gains `help layer:` and
`help figures:`, the latter being the ten surfaces' window-coordinate rectangles that the manual's
annotated screenshots crop their boxes from. Two of them are what the D-Bus-free headless run can
still not decide, and both are limitations rather than defects: export carries no "?" (it is a menu
action with no surface of its own, and a popover inside the menu's popover is the nested-popover trap
the invariants already warn about), and the tour card cannot miss its target when the target is the
whole content -- which is what the plot is at the window's 756 px minimum, where the split view
overlays the side panel instead of putting it beside the plot. The probe prints both rectangles
rather than asserting a placement, so that case reads as the geometry it is.

Also fixed while it was found: `gtk_widget_measure()` includes the widget's own margins (measured: a
280 px box with `margin-start 453` reports 733), so a measure that is used to *place* the widget is a
feedback loop -- that is what made the tour card walk between the corner and the target from frame to
frame.
This commit is contained in:
Martino Ferrari committed 2026-10-14 17:02:29 +02:00
1 parent c1460a6977
commit 1baa58b8fd
20 files changed
+1558 -30

No files matched your search

+95
View File
@@ -872,6 +872,87 @@ built, which for a program under development is what you want. No `StartupWMClas
GTK takes the WM class from the application ID, and the desktop file's basename is that same
string, which is what associates a running window with the entry.
## The Help submenu, the contextual help and the guided tour
**The hamburger menu's help trio became a `Help` submenu**, which is where every GNOME menu keeps
these four: the **Keyboard shortcuts** overview and the **Documentation** window (the two dialogs
the program already had, reached through `show_shortcuts()`/`show_help()` exactly as their keys
reach them), the **Guided tour**, and a stateful **Enable contextual help** row.
The submenu *replaces* the menu's rows rather than floating beside them -- the same shape, and the
same reason, as the plot's right-click menu: a submenu torn out of the menu it belongs to is a
second thing to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the
moment anything else closed its popover. It is a `GtkStack` whose visible page is the view, and not
a rebuild of the box: the main view holds `app.layout_toggle`, which the frame tick reads
(`sync_layout_editor()`) and the `L` accel moves, so rebuilding the rows would leave that pointer on
a destroyed widget, and *swapping the popover's child* would leave the view that is not showing
without an owner. A page swap has neither problem.
**The accelerators are `Ctrl+/` and `F1` for the two dialogs -- they already were -- plus `Ctrl+T`
for the tour and `Ctrl+H` for the contextual help.** Both new keys are rows in
`src/shortcuts.cpp` *and* in `src/ui/shortcuts.ui`, because the `[help]` probe checks the two
against each other (`ui` must equal `tables`).
**The contextual help is one table and a "?" per surface.** `src/ui_context_help.cpp` carries the
rows -- the plot, the trigger, the cursors, the measurements, the math channels, the signals, the
event history, the time window, the status line, and the math editor's identity row -- and each row
names the *heading box* its button joins, so adding a surface is a row and a widget pointer. The
button is a sibling of the heading's title, not an overlay on its corner, for the reason the
toolbar's icons are not overlaid either: an overlay child sits *on* whatever is under it, and the
one surface whose corner is free (the plot) is the one that uses one.
**Off means gone, not greyed.** The toggle removes the buttons rather than disabling them, and
which buttons exist is decided by a data key rather than a list of pointers, so the math editor --
whose heading is created afresh every time it opens -- cannot leave a stale pointer behind. The
setting is `contextual_help` in `settings.toml`, written by
`Settings`/`load_settings()`/`save_settings()`: the same file, the same three functions and the same
`~/.config/udpscope/settings.toml` the Preferences dialog writes. The dialog's own rows start from
`load_settings()`, so a save from there carries the two help keys through untouched; the Help menu's
row is the only place that states `contextual_help`, which is why the row's check *is* the setting.
**The guided tour is five steps on a `GtkOverlay`** inserted above the window's content: the plot,
the side panel, the trigger, the signals and the status line. There is no popover anywhere in it and
therefore no pointer grab to lose: what makes the tour modal is four dim rectangles -- above, below
and either side of the target -- that swallow the clicks that land on them, and
`gtk_widget_is_visible()` decides which steps exist at all (a widget under a hidden parent keeps its
own flag true, so `get_visible()` would have walked over surfaces that are not there).
Everything is placed from `gtk_widget_compute_bounds()`, re-read on **every frame**, because 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 hole is kept from the last frame that could measure the target for the
same reason: a frame can arrive between two layout passes, and a hole that emptied there would make
the spotlight and the card jump. The card goes under the target, over it, or in the column beside it
-- whichever has room -- and only takes the bottom corner when the target *is* the whole content,
which is what happens to the plot step in the documented headless run (a 640x480 Xvfb screen gives
the window its 756x894 minimum, and at that width the split view overlays the side panel instead of
putting it beside the plot). The probe prints the two rectangles (`spot=`, `card=`) rather than
asserting a placement, so that case reads as the geometry it is.
**A first run gets the tour, once.** The decision is `settings.tour_seen`, and it is a key of its
own rather than "the settings file did not exist": a user who had already changed a theme, or the
cache size, has a settings file and has still never seen the tour. It is written when the tour
**starts**, not when it finishes, because a user who skips it in the first second has still seen it
and a second launch that offered the whole tour again would be a nuisance. The auto-start is
scheduled 1.5 s after the window is built -- the window has to be up and laid out for there to be
anything to point at -- and the menu (or `Ctrl+T`) starts the same tour at any time.
Dismissing it is `Skip`, `Finish` on the last step, or `Escape`; all three hide every child of the
overlay. The probe asserts what "left nothing behind" means as a *reading*: four dim rectangles,
the outline and the card all off screen, and **the window carrying exactly the event controllers it
had before the tour opened**.
`UDPSCOPE_PROBE_HELPUI_MS` drives all of it: the first-run auto-start, the menu's seven rows and the
submenu's five, the toggle and the count of "?" affordances on either side of it, and the tour
started, stepped forwards and back, and dismissed. Its readings are in
[probe-readings.md](docs/claude/probe-readings.md).
**The dump also carries the geometry the help's annotated figures crop**, `help figures:`, with each
of ten surfaces' `x,y,width,height` in **window coordinates** -- the frame a screenshot is cropped
in -- or `-1,-1,0,0` when the surface is not on screen. It is measured on the tick while the window
is alive, and it exists because a crop box drawn round a region of a screenshot has to be measured
rather than eyeballed: the panels are where `gtk_widget_compute_bounds()` says they are, and a
plausible guess is not.
## How the spec maps onto the code
**One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel`
@@ -2541,6 +2622,20 @@ silence timeout and then resumes, which is the EMI case end to end.
machine and its integration with the buffers, and rendering under Xvfb
(llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs,
the right-click menu) were not driven by an automated test.
- **The contextual help has no "?" on the export row.** Nine surfaces carry one;
export is a *menu action* and has no surface of its own to carry anything, and the
obvious alternative -- a "?" on the row inside the menu's popover, opening a
popover of its own -- is the nested popover the invariants already warn about: the
inner popover takes the grab, the menu closes under the click, and the bubble is
left parented to a row in a hidden popover. Export is described in the
Documentation instead.
- **The tour card can cover its own target, and in the documented headless run it
does.** The card is placed under, over or beside the hole whenever one of those has
room; when the target covers the whole content -- which is what the plot does at the
window's 756 px minimum, because the split view overlays the side panel rather than
putting it beside the plot -- no placement can avoid it, and the card takes the
bottom corner. The probe prints both rectangles instead of asserting a placement,
so the case reads as a measurement.
**A debug-build assertion in the library's suite.** `deps/plotter`'s debug build prints
`TextBatch::add_cell`'s `glyph_is_cell` assertion once while running its tests — from a