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:
1 parent
c1460a6977
commit
1baa58b8fd
20 files changed
+1558
-30
No files matched your search
@@ -462,3 +462,64 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
||||
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.
|
||||
- **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 "?" 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.
|
||||
- **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 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 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
|
||||
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. 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.
|
||||
- **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()`, 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 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.
|
||||
@@ -395,6 +395,30 @@ Two lines of the self-check output are assertions in disguise:
|
||||
on the first tick for the same reason the toolbar's are. **Both must read `(ok)`**: the page titles are
|
||||
empty on purpose, so an icon that does not resolve leaves a *blank* pill, and a blank pill looks
|
||||
exactly like a page with nothing in it.
|
||||
- `help layer: contextual=on hints=8 ids={events plot status cursors measures trigger signals window} icon=dialog-question-symbolic(ok); tour=idle steps=5 step=0 auto=1` (in the exit
|
||||
dump, beside `switcher:`) — the help affordance layer as the last tick found it. **`hints=` must equal
|
||||
the number of ids**, and the ids are the surfaces the contextual help is on: the eight of the window
|
||||
itself, and `math` only while the math editor is open (its heading lives in a builder that is rebuilt
|
||||
on every opening, so it is not one of the window's own). `contextual=off` beside `hints=0` is the
|
||||
toggle's other state — **off is *gone*, not greyed**, so a non-zero count with `off` is a row that hid
|
||||
its buttons instead of removing them. **`icon=` must read `(ok)`**: a `?"` whose name Adwaita lacks is a
|
||||
*blank* control that still works, the failure the pane [edit] buttons already paid for once. `tour=`
|
||||
is `running`/`idle` with the plan's `steps=` and the current `step=`, and `auto=1` is the first-run
|
||||
auto-start having fired *in this process* — the flag is `tour_seen` in `settings.toml`, so a second run
|
||||
against the same config directory reads `auto=0`.
|
||||
- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… side_switcher=… side_stack=… window_button=… status_label=… header_bar=… plot=…` (in the exit
|
||||
dump, beside `help layer:`) — the **window-coordinate** rectangle of each surface the help's annotated
|
||||
figures label, `x,y,width,height`, or `-1,-1,0,0` for a surface that is **not on screen**. It is
|
||||
measured on the tick while the window is alive (nothing in the dump may call `gtk_widget_*` — see
|
||||
[verification.md](verification.md)) and with `gtk_widget_is_visible()`, not `get_visible()`: a widget
|
||||
under a hidden parent — `trigger_summary` while the pane is in *configuration* mode, the Measure page
|
||||
while the Signals tab is showing — keeps its own flag true and reads `0x0`, and a crop box drawn from
|
||||
that is a box over the wrong thing. The figures are produced by cropping a real screenshot, so these
|
||||
numbers **are** the crop boxes; their values depend on the window's size, which under the documented
|
||||
`xvfb-run` is the window's 756x894 minimum (the 640x480 screen is smaller than the content), so a run
|
||||
on a larger screen reads different numbers for the same panels. `trigger_summary` reading
|
||||
`-1,-1,0,0` is the *configuration* pane rather than a defect: it is the locked pane's content, and
|
||||
`UDPSCOPE_PROBE_HELPUI_MS` locks the pane for that leg.
|
||||
- `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump,
|
||||
beside `toolbar icons:`) — the application icon, named and present at the size its own root
|
||||
`<svg>` element declares. **Both entries must read `ok` at those sizes.** `ok` is
|
||||
@@ -638,7 +662,55 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
|
||||
every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses
|
||||
itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores
|
||||
it afterwards.
|
||||
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: tab signals->measure | g: tab measure->signals | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The `m`/`g` pairs are the *tab name* before and after, which is what says those two keys select a tab rather than toggling a pane; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
|
||||
- `[helpui] auto_started=1 visible=1 steps=5 step=0 (first run)` (stderr, `UDPSCOPE_PROBE_HELPUI_MS`) —
|
||||
the help affordance layer end to end, in ten steps 300-400 ms apart (every reading is an
|
||||
*allocation*, and a step that read its own predecessor's geometry would read a rectangle from before
|
||||
the frame that placed it). The line above is the **first-run auto-start**, read before the probe
|
||||
touches anything: `auto_started=1 visible=1` is the tour that started itself, and the parenthetical is
|
||||
`settings.tour_seen` as the process found it — `tour_seen already set` with `auto=0` is the *second*
|
||||
run against the same config directory, which is the assertion that the flag persisted. Then:
|
||||
- `[helpui] contextual on: hints=8 ids={events plot status cursors measures trigger signals window}` —
|
||||
the eight "?" affordances, by id. The layer's ninth surface, `math`, is not here: its host exists
|
||||
only while the math editor window is open.
|
||||
- `[helpui] main view: rows=7 help_row=1` and one `[helpui] entry N '<label>' accel='<key>' order=O`
|
||||
per row of the **Help view** — `entry 0 'Back'` with no accelerator and then the four the submenu
|
||||
promises: `entry 1 'Keyboard shortcuts' accel='Ctrl+/'`, `entry 2 'Documentation' accel='F1'`,
|
||||
`entry 3 'Guided tour' accel='Ctrl+T'`, `entry 4 'Enable contextual help' accel=''`. **`order=1` on
|
||||
every row** is the assertion; the view is entered by emitting `::clicked` on the menu's own `Help`
|
||||
row, so the row's handler is exercised and not just the stack.
|
||||
- `[helpui] help view: rows=5 expected=5 rows_bad=0 back=1 toggle=1 entries='…'` — the summary, with
|
||||
`toggle=1` being the contextual-help row's check, which *is* `settings.contextual_help`.
|
||||
- `[helpui] toggle: off -> hints=0 ids={}; on -> hints=8 ids={…}; setting=on` — the toggle's effect
|
||||
read both ways through the row itself. **`hints=0` with `off` is the assertion**: off means the
|
||||
affordances are *gone*, not greyed, so anything else is a row that hid its buttons instead of
|
||||
removing them. The count comes back to the same eight, which is the other half — a toggle that
|
||||
removed and could not restore is a one-way switch.
|
||||
- `[helpui] tour started from the probe: visible=1 steps=5 step=0` — the tour started the way
|
||||
`Ctrl+T` starts it. `steps=5` is the plan: the table's five steps whose target was on screen
|
||||
(`gtk_widget_is_visible()`, so a collapsed pane is not walked over).
|
||||
- `[helpui] step=1/5 visible=1 spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` — **in window
|
||||
coordinates**, the frame a screenshot is cropped in. **`contains` must be 1** on every step: the
|
||||
spotlight is the target's own rectangle grown three pixels. `clear` is the card missing the target,
|
||||
and it is 1 wherever the window has room for the card beside or above/below the hole — at the
|
||||
documented run's window (see `help figures:` below) the plot's step is *the whole content*, because
|
||||
at 756 px the split view overlays the side panel rather than putting it beside the plot, so
|
||||
`clear=0` there is the geometry and not a placement bug. The side panel's step reads `clear=1`.
|
||||
- `[helpui] after Next: step=2/5 visible=1`, `[helpui] after Back: step=1/5 visible=1`,
|
||||
`[helpui] after Skip: visible=0 dims_hidden=1 spot_hidden=1 card_hidden=1 controllers=12 (was 12)
|
||||
click_gestures=2` — the three controls. **`controllers=` equal to `(was N)` is the "no lingering
|
||||
input grab" reading**: a tour of ordinary overlay widgets takes no grab at all, and a count that
|
||||
grew would be one somebody added.
|
||||
- `[helpui] layer: …` — `capture_help_state()`'s line at the end of the run, the same text the exit
|
||||
dump prints as `help layer:`.
|
||||
- `[helpui] figures locked: …` and `[helpui] figures config: …` — `help figures:` read in both of the
|
||||
trigger pane's modes, by clicking its [edit] button. This exists because one of the help's figures
|
||||
is the *locked* pane's summary grid (`trigger_summary`), which in configuration mode is not on
|
||||
screen at all and reads `-1,-1,0,0`; the two lines give the geometry agent both states' numbers
|
||||
rather than leaving them to find the mode. `trigger_panel`'s height differs between them (508 px in
|
||||
configuration mode against 72 locked — the body is replaced by the summary), which is the check
|
||||
that the mode really moved.
|
||||
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: tab signals->measure | g: tab measure->signals | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**.
|
||||
The `m`/`g` pairs are the *tab name* before and after, which is what says those two keys select a tab rather than toggling a pane; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
|
||||
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
|
||||
- `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N
|
||||
open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` (stderr,
|
||||
|
||||
@@ -303,6 +303,20 @@ as distinct from the fault injection below, which exercises ingest:
|
||||
`GtkMenuButton`-owned submenu failed, its row going dead on the second click. Each view is
|
||||
measured a step after the click that swapped it in, because a popover allocates its new child on
|
||||
the next layout pass.
|
||||
- `UDPSCOPE_PROBE_HELPUI_MS` — the help affordance layer, end to end, in ten steps 300–400 ms apart:
|
||||
the **first-run auto-start** (run it once with a throwaway `XDG_CONFIG_HOME` to see `auto=1`, and
|
||||
again against the same one to see it not repeat), the menu's seven rows and the **four entries of
|
||||
the Help submenu**, the **contextual-help toggle** with the count of "?" affordances on either side
|
||||
of it, the tour **started from the probe** and stepped forwards, back and dismissed, and `help
|
||||
figures:` read in both of the trigger pane's modes. Its lines and what each asserts are in
|
||||
[probe-readings.md](probe-readings.md); the two things to read first are **`hints=0`** while the
|
||||
toggle is off (off is *gone*, not greyed) and **`controllers=12 (was 12)`** after the dismissal (no
|
||||
lingering input grab — a tour of ordinary overlay widgets takes none). Give it
|
||||
`UDPSCOPE_EXIT_AFTER_MS=9500 UDPSCOPE_EXIT_DUMP=1` to get the `help layer:`/`help figures:` dump
|
||||
lines as well, and note the tour **auto-starts** in any run whose settings file does not carry
|
||||
`tour_seen`, so a probe run against a fresh config directory will have it on screen for the first
|
||||
second and a half — and that start *writes* `tour_seen` to whichever config directory the run is
|
||||
using, which is why the second run against the same one reads `auto=0`.
|
||||
- `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the
|
||||
trigger panel: `[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01`. A
|
||||
`GtkSpinButton` starts at its range's *minimum*, so a `-1e12` here is an unseeded button the
|
||||
|
||||
Reference in new issue
Block a user