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

+2
View File
@@ -221,6 +221,8 @@ add_executable(udpscope
src/ui_layout.cpp
src/ui_trigger.cpp
src/ui_signals.cpp
src/ui_context_help.cpp
src/ui_tour.cpp
)
target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
+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
+61
View File
@@ -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.
+73 -1
View File
@@ -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,
+14
View File
@@ -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
+9
View File
@@ -66,6 +66,10 @@ Settings load_settings() {
s.relative_time = parse_bool(value, s.relative_time);
} else if (key == "theme") {
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
} else if (key == "contextual_help") {
s.contextual_help = parse_bool(value, s.contextual_help);
} else if (key == "tour_seen") {
s.tour_seen = parse_bool(value, s.tour_seen);
}
}
return s;
@@ -80,6 +84,11 @@ bool save_settings(const Settings& settings) {
out << "event_cache_max = " << settings.event_cache_max << "\n";
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
out << "theme = \"" << settings.theme << "\"\n";
// The two help keys are the *app's* memory rather than the dialog's: the contextual-help toggle in
// the Help menu writes the first, and the first-run tour writes the second. `read_rows()` in the
// preferences starts from `load_settings()`, so a dialog save carries them through untouched.
out << "contextual_help = " << (settings.contextual_help ? "true" : "false") << "\n";
out << "tour_seen = " << (settings.tour_seen ? "true" : "false") << "\n";
out.flush();
return out.good();
}
+7
View File
@@ -33,6 +33,13 @@ struct Settings {
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
std::string theme = "auto";
/// The contextual-help layer: a small "?" beside each surface it can explain. On by default -- an
/// affordance nobody can find is not help -- and off is a decision that survives a restart.
bool contextual_help = true;
/// Whether the guided tour has been offered. A first run has no file carrying this key, so the tour
/// starts itself once; the flag is written when it *starts*, not when it finishes, because a user who
/// skips it has still seen it and a second launch with the same tour would be a nuisance.
bool tour_seen = false;
};
/// The defaults, as a value rather than as a comment: what the dialog's *Defaults* button writes and
+5 -1
View File
@@ -22,9 +22,13 @@ constexpr Accel kAccels[] = {
"Panes and editors"},
{GDK_KEY_e, GDK_CONTROL_MASK, AppAction::Export, "<Control>e",
"Export the visible range", "Files and windows"},
{GDK_KEY_F1, 0, AppAction::Help, "F1", "Open the help window", "Files and windows"},
{GDK_KEY_F1, 0, AppAction::Help, "F1", "Open the documentation", "Files and windows"},
{GDK_KEY_slash, GDK_CONTROL_MASK, AppAction::Shortcuts, "<Control>slash",
"Open this shortcut overview", "Files and windows"},
{GDK_KEY_t, GDK_CONTROL_MASK, AppAction::GuidedTour, "<Control>t",
"Start the guided tour", "Files and windows"},
{GDK_KEY_h, GDK_CONTROL_MASK, AppAction::ContextualHelp, "<Control>h",
"Turn the contextual help on or off", "Files and windows"},
{GDK_KEY_q, GDK_CONTROL_MASK, AppAction::Quit, "<Control>q", "Quit udpscope",
"Files and windows"},
};
+2
View File
@@ -17,6 +17,8 @@ enum class AppAction {
Export,
Help,
Shortcuts,
GuidedTour,
ContextualHelp,
Quit,
};
+206 -24
View File
@@ -2002,6 +2002,11 @@ void open_math_dialog(App& app, int edit) {
}
GtkWidget* window = d->window;
gtk_window_set_title(GTK_WINDOW(window), edit >= 0 ? "Edit math channel" : "New math channel");
// The contextual-help "?" for this editor. This is the one surface whose heading only exists while a
// dialog is open -- the file is rebuilt on every opening -- so it cannot be a row the window's own
// build visits: the row is still the table's, and this states its host and asks for the hint.
app.math_head = head;
context_help_add(app, "math", head);
// The dialog's own state, freed with the window: a plain local here would leak the graph and every drag
// it is halfway through on each opening.
g_object_set_data_full(G_OBJECT(window), "udpscope-math-dialog", d,
@@ -2010,7 +2015,13 @@ void open_math_dialog(App& app, int edit) {
GtkWidget* dd = GTK_WIDGET(w);
MathDialog* md = static_cast<MathDialog*>(
g_object_get_data(G_OBJECT(dd), "udpscope-math-dialog"));
if (md != nullptr && md->app != nullptr) md->app->math_dialog = nullptr;
if (md != nullptr && md->app != nullptr) {
// The host goes with the window, and the builder that owns it is dropped on the
// next opening: clear the pointer here rather than leaving it aimed at widgets
// that are about to be finalized.
md->app->math_head = nullptr;
md->app->math_dialog = nullptr;
}
return false;
}),
nullptr);
@@ -2311,6 +2322,9 @@ GtkWidget* build_measures_panel(App& app) {
update_measures(*a, /*force=*/true, /*accumulate=*/true);
}),
&app);
// The heading row -- the .ui's anonymous first child, carrying the title and the `+`. It is where
// the contextual help's "?" joins (see `ui_context_help.cpp`).
app.measures_head = gtk_widget_get_first_child(box);
return box;
}
@@ -2430,32 +2444,70 @@ void on_export_clicked(GtkButton*, gpointer user) {
g_object_unref(dialog);
}
/// Dismisses the overflow menu. Every entry that opens something *else* -- a dialog, a window, the
/// tour -- does this first, or the menu is still up behind what it opened.
void close_overflow_menu(App& app) {
if (app.menu_button == nullptr) return;
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app.menu_button));
if (popover != nullptr) gtk_popover_popdown(popover);
}
void on_about_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->menu_button != nullptr) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_popdown(popover);
}
close_overflow_menu(*app);
show_about(GTK_WINDOW(app->window),
layout_path(app->model->config().server, app->model->config().port));
}
void on_help_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->menu_button != nullptr) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_popdown(popover);
}
close_overflow_menu(*app);
show_help(GTK_WINDOW(app->window));
}
void on_shortcuts_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
close_overflow_menu(*app);
show_shortcuts(GTK_WINDOW(app->window));
}
/// The Help submenu's two rows beyond the dialogs. The view swap is the plot menu's own shape: the
/// second view *replaces* the first, with a Back row at its head, because a submenu torn out of the
/// menu it belongs to is a second thing to dismiss and a `GtkMenuButton` that owned one toggled
/// itself out of sync the moment anything else closed its popover.
///
/// What it does *not* do is rebuild the rows. The main view holds `app.layout_toggle`, which the tick
/// reads (`sync_layout_editor()`) and which the `L` accel moves, so a rebuild would leave that pointer
/// aimed at a destroyed widget; the two views are pages of a `GtkStack` instead, and a view is swapped
/// by naming the page.
void on_menu_show_help(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->menu_stack == nullptr) return;
gtk_stack_set_visible_child_name(GTK_STACK(app->menu_stack), "help");
// The popover's content changed size, which is what `present()` re-allocates and re-positions: a
// popover that is already up does not re-measure itself.
if (app->menu_button != nullptr) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_popdown(popover);
if (popover != nullptr) gtk_popover_present(popover);
}
show_shortcuts(GTK_WINDOW(app->window));
}
void on_menu_back(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->menu_stack == nullptr) return;
gtk_stack_set_visible_child_name(GTK_STACK(app->menu_stack), "main");
if (app->menu_button != nullptr) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_present(popover);
}
}
/// "Guided tour": the menu hands over to the tour module, and the tour is startable at any time --
/// from here, from `<Control>t`, and by itself on a first run.
void on_tour_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
close_overflow_menu(*app);
start_tour(*app);
}
// --- right-click menu -------------------------------------------------------
@@ -3263,6 +3315,11 @@ gboolean tick(gpointer user) {
// no text and no name either.
name_side_tabs(*app);
note_panel_controls(*app);
// The tour, re-measured every frame: the target's allocation is 0 on the frame it is first shown
// (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`) and the widget
// moves under the spotlight on a window resize, so a one-shot measurement is wrong both ways.
// Cheap when the tour is not running: one flag.
update_tour(*app);
ScopeModel& model = *app->model;
const gint64 tick_start_us = g_get_monotonic_time();
@@ -3311,6 +3368,17 @@ gboolean tick(gpointer user) {
std::fflush(stderr);
}
}
// The help layer's own readings and the bounds the help's annotated figures crop, on the same
// once-a-second cadence and for the same reason as the line above: the dump runs after the window
// is destroyed and `verification.md` forbids a `gtk_widget_*` call there.
{
static gint64 next_help_log = 0;
const gint64 now = g_get_monotonic_time();
if (now >= next_help_log) {
next_help_log = now + G_TIME_SPAN_SECOND;
capture_help_state(*app);
}
}
// Captured every frame so the dump has the last live value: see `dbg_measures_pane_w`.
if (app->measures_list != nullptr) {
app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list);
@@ -4516,6 +4584,14 @@ void run_app_action(App& app, AppAction action) {
case AppAction::Shortcuts:
show_shortcuts(GTK_WINDOW(app.window));
break;
case AppAction::GuidedTour:
start_tour(app);
break;
case AppAction::ContextualHelp:
// Through the menu's own row rather than around it: the check *is* the setting, and the row's
// handler is the one place that writes it.
toggle(app.contextual_toggle);
break;
case AppAction::Quit:
if (app.window != nullptr) gtk_window_close(GTK_WINDOW(app.window));
break;
@@ -4528,6 +4604,12 @@ gboolean on_app_key_pressed(GtkEventControllerKey*, guint keyval, guint, GdkModi
gpointer user) {
App* app = static_cast<App*>(user);
if (app->window == nullptr) return FALSE;
// Escape ends the tour, and it does so ahead of the editable check: a tour that cannot be dismissed
// with the key every one of them teaches is a tour with a trap in it.
if (keyval == GDK_KEY_Escape && tour_active(*app)) {
tour_dismiss(*app);
return TRUE;
}
GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window));
if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE;
@@ -4589,6 +4671,9 @@ GtkWidget* build_toolbar(App& app) {
// has already packed, so a control added here would land right of the whole group.
if (GtkWidget* slot = GTK_WIDGET(gtk_builder_get_object(ui, "window_control_slot"))) {
gtk_box_append(GTK_BOX(slot), build_window_control(app));
// Where the contextual help's "?" for the span control joins: the slot, not the bar, because a
// control added to a header bar lands right of everything the file packed.
app.window_control_slot = slot;
}
refresh_window_limits(app);
@@ -4608,8 +4693,9 @@ GtkWidget* build_toolbar(App& app) {
// because these act on the `RowSet` directly and two of them are toggles whose state the
// rest of the UI reads, so an action model would only add a second copy of that state.
//
// The order is a GNOME menu's: what you do to the *view*, then the data, then the help
// trio. Nothing here needs a separator of its own -- the groups are the separators.
// The order is a GNOME menu's: what you do to the *view*, then the data, then Help, then the
// two about-the-program entries. Nothing here needs a separator of its own -- the groups are
// the separators.
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(menu_box, 6);
gtk_widget_set_margin_bottom(menu_box, 6);
@@ -4649,16 +4735,19 @@ GtkWidget* build_toolbar(App& app) {
gtk_box_append(GTK_BOX(menu_box), menu_separator());
GtkWidget* shortcuts_item = menu_button("Keyboard shortcuts", "Ctrl+/");
gtk_widget_set_tooltip_text(shortcuts_item,
"every key and mouse gesture, including the plot's own");
g_signal_connect(shortcuts_item, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), shortcuts_item);
GtkWidget* help_item = menu_button("Help", "F1");
gtk_widget_set_tooltip_text(help_item, "the paged guide to every feature");
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), help_item);
// The Help submenu's door. A row and not a `GtkMenuButton`: the submenu is the *next view of this
// menu*, and the chevron is what says so -- the same shape, and the same reason, as the plot menu's
// Remove-signal row.
GtkWidget* help_row = menu_button("Help", nullptr);
gtk_widget_set_tooltip_text(help_row,
"the shortcut list, the documentation, a guided tour and the "
"contextual help");
g_signal_connect(help_row, "clicked", G_CALLBACK(on_menu_show_help), &app);
if (GtkWidget* inner = gtk_button_get_child(GTK_BUTTON(help_row))) {
gtk_box_append(GTK_BOX(inner), gtk_image_new_from_icon_name("pan-end-symbolic"));
}
app.menu_help_row = help_row;
gtk_box_append(GTK_BOX(menu_box), help_row);
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
@@ -4672,6 +4761,61 @@ GtkWidget* build_toolbar(App& app) {
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
gtk_box_append(GTK_BOX(menu_box), about_item);
// --- the Help view, the menu's second page --------------------------------
// Exactly the four entries the menu promises: the two dialogs that already existed, the tour, and
// the switch for the "?" layer. The two dialogs are the *same* entry points the keys reach --
// `show_shortcuts()` and `show_help()` -- so the menu and the accelerator cannot drift apart.
GtkWidget* help_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(help_box, 6);
gtk_widget_set_margin_bottom(help_box, 6);
gtk_widget_set_margin_start(help_box, 6);
gtk_widget_set_margin_end(help_box, 6);
GtkWidget* back_row = menu_icon_button("go-previous-symbolic", "Back");
g_signal_connect(back_row, "clicked", G_CALLBACK(on_menu_back), &app);
app.menu_back_row = back_row;
gtk_box_append(GTK_BOX(help_box), back_row);
gtk_box_append(GTK_BOX(help_box), menu_separator());
app.menu_shortcuts_row = menu_button("Keyboard shortcuts", "Ctrl+/");
gtk_widget_set_tooltip_text(app.menu_shortcuts_row,
"every key and mouse gesture, including the plot's own");
g_signal_connect(app.menu_shortcuts_row, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
gtk_box_append(GTK_BOX(help_box), app.menu_shortcuts_row);
app.menu_docs_row = menu_button("Documentation", "F1");
gtk_widget_set_tooltip_text(app.menu_docs_row,
"the udpscope manual, opened in the desktop help viewer");
g_signal_connect(app.menu_docs_row, "clicked", G_CALLBACK(on_help_clicked), &app);
gtk_box_append(GTK_BOX(help_box), app.menu_docs_row);
app.menu_tour_row = menu_button("Guided tour", "Ctrl+T");
gtk_widget_set_tooltip_text(app.menu_tour_row,
"walk over the plot, the side panel, the trigger, the signals and "
"the status line, one step at a time");
g_signal_connect(app.menu_tour_row, "clicked", G_CALLBACK(on_tour_clicked), &app);
gtk_box_append(GTK_BOX(help_box), app.menu_tour_row);
// The check is the setting: `menu_toggle()` states it before connecting, so the row that starts
// checked does not fire the handler while the surfaces it acts on are still being built.
app.contextual_toggle = menu_toggle("Enable contextual help", nullptr,
app.settings.contextual_help);
gtk_widget_set_tooltip_text(app.contextual_toggle,
"a small \u201c?\u201d beside each surface, saying what that surface "
"is for");
g_signal_connect(app.contextual_toggle, "toggled", G_CALLBACK(on_contextual_help_toggled), &app);
gtk_box_append(GTK_BOX(help_box), app.contextual_toggle);
// The two views, as the pages of a stack: the popover's child is the stack and the visible child is
// the view. Swapping the *page* rather than rebuilding the box is what keeps `app.layout_toggle` --
// which points into the main view and which the tick reads -- a live widget.
app.menu_main_view = menu_box;
app.menu_help_view = help_box;
app.menu_stack = gtk_stack_new();
gtk_stack_set_transition_type(GTK_STACK(app.menu_stack), GTK_STACK_TRANSITION_TYPE_NONE);
gtk_stack_add_named(GTK_STACK(app.menu_stack), menu_box, "main");
gtk_stack_add_named(GTK_STACK(app.menu_stack), help_box, "help");
// --- the pane toggles, which live on the bar rather than in the menu ------
// The three pane toggles, and the rest of the bar, are the file's now; their handlers are wired where
// the bar's ids are resolved, below. The popover above still shares this function's styling helpers
@@ -4679,7 +4823,7 @@ GtkWidget* build_toolbar(App& app) {
GtkWidget* popover = gtk_popover_new();
gtk_widget_add_css_class(popover, "udpscope-menu");
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
gtk_popover_set_child(GTK_POPOVER(popover), app.menu_stack);
app.menu_button = GTK_WIDGET(gtk_builder_get_object(ui, "menu_button"));
app.sidebar_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "sidebar_toggle"));
app.trigger_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_toggle"));
@@ -4734,6 +4878,9 @@ void on_activate(GtkApplication* application, gpointer user) {
app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge"));
app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label"));
app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info"));
// The status line's row, which is where the status line's own "?" joins and one of the surfaces the
// help's annotated figures label.
app->status_row = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_row"));
GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column"));
GtkWidget* side_stack = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_stack"));
GtkWidget* side_switcher = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_switcher"));
@@ -4744,6 +4891,7 @@ void on_activate(GtkApplication* application, gpointer user) {
if (app->window == nullptr || side_view == nullptr || events_divider == nullptr ||
app->plot_box == nullptr || app->plot_split == nullptr || app->status_badge == nullptr ||
app->status_label == nullptr || app->status_info_button == nullptr ||
app->status_row == nullptr ||
side == nullptr || side_stack == nullptr || side_switcher == nullptr ||
measure_page == nullptr || signals_page == nullptr) {
return;
@@ -4769,6 +4917,19 @@ void on_activate(GtkApplication* application, gpointer user) {
// shows are the window's, and those are in the file.
gtk_window_set_titlebar(GTK_WINDOW(app->window), build_toolbar(*app));
// The guided tour's overlay goes *above* the window's content, between the window and the split
// view: its children -- the four dim rectangles, the spotlight's outline and the card -- have to
// be able to cover the whole content, and a widget placed anywhere inside the split view could
// only ever cover its own half of it. The split view keeps its reference from the builder, so
// handing the window a new child and then adopting the old one costs no reference bookkeeping.
{
GtkWidget* overlay = gtk_overlay_new();
gtk_window_set_child(GTK_WINDOW(app->window), overlay);
gtk_overlay_set_child(GTK_OVERLAY(overlay), side_view);
app->tour_overlay = overlay;
build_tour(*app);
}
// The size the window opens at, stated in code as well as in the `.ui`: `default-width` there is the
// size it *asks* for, and a window whose content has a larger minimum opens at that minimum instead --
// 756 px here, the plot column's own minimum plus the sidebar's 320. The old code set it explicitly;
@@ -4974,6 +5135,16 @@ void on_activate(GtkApplication* application, gpointer user) {
rebuild_signal_panels(*app);
g_timeout_add(kTickMs, tick, app);
// The contextual-help layer, once every surface it names exists. It reads the setting, so a user
// who turned it off meets a window with no "?" on it and a menu whose row is unchecked.
context_help_sync(*app);
// A first run is the run whose settings file does not say the tour has been offered -- see
// `tour_autostart()`. Delayed rather than fired on the first tick: the window has to be up and
// laid out for the tour to have anything to point at, and a tour that opened on the frame the
// window appeared would be a spotlight on a widget with no allocation yet.
if (!app->settings.tour_seen) g_timeout_add(1500, tour_autostart, app);
// Bubble phase on purpose: a focused GtkEditable or GtkButton wins its own keys, so typing
// 'm' into the window entry does not toggle the measurements pane and Space does not
// double-fire on a focused button.
@@ -5601,6 +5772,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), help_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELPUI_MS")) {
helpui_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_CONFIGS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), configs_probe, &app);
}
@@ -5855,6 +6029,14 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_names,
app.dbg_switcher_icon_w, app.dbg_switcher_icon_h, app.dbg_switcher_w,
app.dbg_switcher_h);
// The help affordance layer: what the contextual-help toggle left on the surfaces, and where the
// guided tour stood when the last capture ran. Both are captured on the tick (`capture_help_state`),
// so neither asks a destroyed widget anything.
std::printf("[udpscope] help layer: %s\n", app.dbg_context_help.c_str());
// The bounds the help's annotated figures draw their crop boxes from: window coordinates, or
// -1,-1,0,0 for a widget that is not on screen (`is_visible()`, not `get_visible()` -- a widget
// under a hidden parent keeps its own flag true). Measured on the tick, after a frame.
std::printf("[udpscope] help figures: %s\n", app.dbg_help_figures.c_str());
std::printf("[udpscope] mode with trigger on: %s\n", app.dbg_mode_triggered.c_str());
std::printf("[udpscope] trigger marker x=%.3f, view x_max=%.3f; model trigger_time=%.3f "
"valid=%d\n",
+15 -1
View File
@@ -121,7 +121,7 @@
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">F1</property>
<property name="title" translatable="yes">Open the help window</property>
<property name="title" translatable="yes">Open the documentation</property>
</object>
</child>
<child>
@@ -131,6 +131,20 @@
<property name="title" translatable="yes">Open this shortcut overview</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;Control&gt;t</property>
<property name="title" translatable="yes">Start the guided tour</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
<property name="accelerator">&lt;Control&gt;h</property>
<property name="title" translatable="yes">Turn the contextual help on or off</property>
</object>
</child>
<child>
<object class="GtkShortcutsShortcut">
<property name="visible">1</property>
+115
View File
@@ -200,6 +200,73 @@ struct App {
bool search_jump_empty = false;
GtkWidget* menu_button = nullptr;
GtkWidget* cursors_toggle = nullptr;
/// --- the help affordance layer -------------------------------------------
///
/// The overflow menu's two views. The popover's child is a `GtkStack` holding both, and the
/// *visible child* is the view: swapping the stack rather than rebuilding the box is what keeps
/// `layout_toggle` — which points into the main view and is read by the tick — a live widget.
GtkWidget* menu_stack = nullptr;
GtkWidget* menu_main_view = nullptr;
GtkWidget* menu_help_view = nullptr;
/// The Help view's own rows, so the probe can read them and drive them without walking the popover:
/// the two existing dialogs' entry points and the two new actions.
GtkWidget* menu_shortcuts_row = nullptr;
GtkWidget* menu_docs_row = nullptr;
GtkWidget* menu_tour_row = nullptr;
/// The two navigation rows: the main view's door into the Help view, and the Back row out of it.
GtkWidget* menu_help_row = nullptr;
GtkWidget* menu_back_row = nullptr;
/// "Enable contextual help": a stateful menu row whose check *is* the setting.
GtkWidget* contextual_toggle = nullptr;
/// The "?" each surface carries, one per row of the table in `ui_context_help.cpp`. These are the
/// heading rows the buttons join — not the surfaces themselves — so a hint is a sibling of a
/// title rather than an overlay on top of a control. `math_head` only exists while the math
/// editor is open, which is why the row is skipped when it is null.
GtkWidget* trigger_head = nullptr;
GtkWidget* cursors_head = nullptr;
GtkWidget* measures_head = nullptr;
GtkWidget* signals_head = nullptr;
GtkWidget* events_head = nullptr;
GtkWidget* math_head = nullptr;
/// The status line's row, and the header bar's slot for the window control: the two surfaces that
/// are not a panel with a heading.
GtkWidget* status_row = nullptr;
GtkWidget* window_control_slot = nullptr;
/// The guided tour. `tour_overlay` is a `GtkOverlay` inserted above the window's content, and the
/// four dim rectangles, the spotlight and the card are its overlay children. `tour_plan` is the
/// steps whose target was on screen when the tour started, so a surface that is not there is not
/// walked over. `tour_hole` is the spotlight's rectangle in the overlay's own coordinates, kept
/// from the last frame that could measure the target: a frame can come between two layout passes,
/// where `gtk_widget_compute_bounds()` answers nothing, and a hole that emptied there would make
/// the spotlight and the card jump for that frame.
GtkWidget* tour_overlay = nullptr;
GtkWidget* tour_dim[4] = {};
GtkWidget* tour_spot = nullptr;
GtkWidget* tour_card = nullptr;
GtkWidget* tour_title = nullptr;
GtkWidget* tour_body = nullptr;
GtkWidget* tour_counter = nullptr;
GtkWidget* tour_back = nullptr;
GtkWidget* tour_next = nullptr;
int tour_hole[4] = {0, 0, 0, 0};
bool tour_running = false;
int tour_index = 0;
std::vector<int> tour_plan;
/// The first-run auto-start: set by the timer that fires it, and read by the probe and the dump.
/// The decision itself is `settings.tour_seen`, which is the whole of "this has happened before".
bool tour_auto_started = false;
/// The help layer's own dump readings, captured on the tick while the window is alive (nothing
/// here may be read in the dump -- see `docs/claude/verification.md`).
std::string dbg_context_help;
std::string dbg_help_figures;
int dbg_tour_steps = -1;
int dbg_tour_step = -1;
int dbg_tour_visible = -1;
/// Which step of the help-affordance probe runs next (`UDPSCOPE_PROBE_HELPUI_MS`), and how many
/// event controllers the window carried before the tour opened -- the reading that says the tour
/// left nothing behind.
int dbg_helpui_step = 0;
int dbg_helpui_controllers = -1;
/// Where the panel controls ended up, decided once at build time: the dump runs after the window
/// is destroyed and cannot ask GTK anything about a widget.
std::string dbg_panel_controls;
@@ -1631,6 +1698,54 @@ void collect_spins(GtkWidget* w, std::vector<double>* out);
gboolean defaults_probe(gpointer user);
gboolean keys_probe(gpointer user);
gboolean help_probe(gpointer user);
void helpui_probe_next(App& a, int step, guint delay_ms);
gboolean helpui_probe_step(gpointer user);
// --- the help affordance layer (ui_context_help.cpp, ui_tour.cpp) ----------
//
// Two things live here and they are not the same thing: the *contextual help* is a "?" on every
// surface the app can explain, and the *guided tour* is one walk over five of them. Both read the
// one setting the Help menu's toggle writes.
/// Adds or removes the "?" affordances so they match `settings.contextual_help`, and does nothing
/// else. Idempotent, so it can be called after the window is built, on every change of the setting
/// and once more when a dialog that carries one of the hosts is opened.
void context_help_sync(App& app);
/// How many "?" affordances are on screen, writing their ids space-separated into `ids`. Found by
/// the data key rather than kept in a list, so a host that died with a dialog cannot leave a stale
/// pointer behind.
int context_help_count(App& app, std::string* ids);
/// The menu row's handler: writes the setting, saves the file and re-states the layer.
void on_contextual_help_toggled(GtkToggleButton* button, gpointer user);
/// The "?" for one surface by id, for a host that only exists while a dialog is open (the math
/// editor is rebuilt on every open, so it cannot be a row the window's own build visits).
void context_help_add(App& app, const char* id, GtkWidget* host);
/// Builds the tour's overlay children (the four dim rectangles, the spotlight outline and the card)
/// inside `app.tour_overlay`. Called once, as the overlay is inserted.
void build_tour(App& app);
/// Starts the tour at its first step and writes `tour_seen`. Safe to call while it already runs --
/// it restarts at step 0.
void start_tour(App& app);
/// Moves `delta` steps, ending the tour on the way past the last one.
void tour_step(App& app, int delta);
/// Ends the tour: every child of the overlay is hidden and nothing of the tour is left on screen.
void tour_dismiss(App& app);
/// Called from the tick while the tour runs: re-measures the target each frame, so a window resize
/// keeps the spotlight on the widget, and places the card where it does not cover it.
void update_tour(App& app);
bool tour_active(const App& app);
/// The widget the current tour step points at, or nullptr when the tour is not on a step. The probe
/// reads the spotlight against it.
GtkWidget* tour_target(App& app);
/// The steps this run actually walks (targets that were on screen when it started), and where it is.
int tour_step_count(const App& app);
int tour_step_index(const App& app);
/// The first-run auto-start's callback, scheduled only when `settings.tour_seen` is false.
gboolean tour_autostart(gpointer user);
/// Captures the layer's own readings and the bounds of the surfaces the help's figures label, while
/// the window is alive -- the dump runs after it is destroyed and may not ask GTK anything.
void capture_help_state(App& app);
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
RowSet& rows);
+256
View File
@@ -0,0 +1,256 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The contextual-help layer: one small "?" beside each surface the application can explain, whose
// bubble says what that surface is for, in a title, a sentence or two, and the key that reaches it
// when there is one.
//
// The surfaces are the table below -- a surface is a row -- and a hint *joins the surface's own
// heading row* rather than being overlaid on it: a sibling of the title can never cover a control,
// which an overlay child in a corner can and does. The one exception is the plot, which has no
// heading: its "?" is an overlay child of the overlay the layout editor already uses, so it costs
// nothing and sits over the plot's top-right corner.
//
// Off means *gone*, not greyed: the toggle removes the buttons instead of disabling them. Which is
// also why the buttons are found by a data key rather than kept in a list here -- the math editor's
// heading dies with its builder on every reopen, and a list of pointers into it would go stale.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <cstdio>
#include <cstring>
#include <string>
namespace udpscope {
namespace ui_impl {
namespace {
/// The "?" glyph. `help-about-symbolic` is already the status bar's information button, so a second
/// thing meaning "help" needs a name of its own: this is Adwaita's question mark, and the dump reads
/// it back through `gtk_icon_theme_has_icon()` -- a hint button whose icon does not resolve is a
/// blank control, the failure the pane [edit] buttons already paid for once.
constexpr const char* kHintIcon = "dialog-question-symbolic";
/// The data key on a hint button, carrying the surface's id as the value. One key is both the mark
/// that says "this is a hint" and the name the probe and the dump report it by.
constexpr const char* kHintKey = "udpscope-help-hint";
} // namespace
/// One surface the contextual-help layer explains.
///
/// `host` is the box the "?" is appended to -- the surface's *heading row* for a panel, the
/// status line's row, the header bar's window-control slot -- or, for the plot, the `GtkOverlay` it
/// becomes an overlay child of. A null host is a surface whose host only exists while a dialog is
/// open: the math editor's, which `open_math_dialog()` states and `context_help_add()` uses.
struct HelpHint {
const char* id; ///< what the probe and the dump call this affordance
const char* title; ///< the bubble's bold first line
const char* body; ///< one short paragraph
const char* shortcut; ///< the key that reaches the surface, as it is printed, or ""
GtkWidget* App::* host;
};
/// The table, which is the whole of the curation: a new surface is a row, and the heading row it
/// names must already exist in `App` (that is what `ui_app.hpp`'s help-layer fields are for).
constexpr HelpHint kHelpHints[] = {
{"plot", "The plot",
"Every signal you have switched on is drawn here, one row per group of traces, with all rows "
"sharing this one time axis. The wheel zooms about the pointer, a drag pans, and a right-click "
"on a row opens that plot's own menu.",
"", &App::area_overlay},
{"trigger", "The trigger",
"Freezes the view on an event you describe: a signal, a condition (an edge, a pulse width, a "
"window, a dropout), a level and the event window around it. Arm it and the view holds the "
"first event that matches, so a pulse that happens once a minute is readable at any window.",
"A", &App::trigger_head},
{"cursors", "The cursors",
"Two movable instruments read against the window on screen: two verticals for time, two "
"horizontals for the value axis, or the two crossings of each trace. What every plotted row "
"reads at the cursor is listed in this pane.",
"C", &App::cursors_head},
{"measures", "The measurements",
"Readings over the visible window: average, RMS, top, peak to peak, amplitude, frequency, a "
"threshold count and more. Statistics under a reading track how it has varied since the last "
"reset, without the plot having to be still.",
"", &App::measures_head},
{"math", "Math channels",
"A channel computed from the streamed ones in a small node graph: sources, gains, filters and "
"arithmetic, wired together and named here. It is a channel like any other, so it can be "
"plotted, measured, searched and exported.",
"", &App::math_head},
{"signals", "The signals",
"Every signal the source declares, each with the switch that acquires it. Nothing is buffered "
"until it is switched on, and only acquired signals appear in the drop targets and the "
"measurement editor. A signal that is dragged onto a plot is drawn there.",
"G", &App::signals_head},
{"events", "The event history",
"Every triggered acquisition the cache keeps, with a picture of each. Open one to read it as a "
"file, or select two or four and have them compared in two plots or overlaid in one.",
"", &App::events_head},
{"window", "The time window",
"How much time is on screen, counted back from the newest sample. Type a span, change its unit, "
"or step by decades; the trigger's event window is framed out of the same span. No gesture on "
"the plot changes it -- only this control does.",
"", &App::window_control_slot},
{"status", "The status line",
"The stream's own report, on its own once-a-second cadence: the display mode, the rate actually "
"arriving, the window on screen and the buffer holding it. The information button beside it "
"opens the counters behind those words.",
"", &App::status_row},
};
constexpr int kHelpHintCount = static_cast<int>(sizeof(kHelpHints) / sizeof(kHelpHints[0]));
namespace {
/// The bubble: a bold title, the paragraph, and the key when there is one. Wrapped at a stated
/// width rather than left to the popover's own measure, or a long sentence is one very wide line.
GtkWidget* hint_popover(const HelpHint& hint) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12);
gtk_widget_set_margin_top(box, 10);
gtk_widget_set_margin_bottom(box, 10);
GtkWidget* title = gtk_label_new(nullptr);
char markup[128];
std::snprintf(markup, sizeof(markup), "<b>%s</b>", hint.title);
gtk_label_set_markup(GTK_LABEL(title), markup);
gtk_label_set_xalign(GTK_LABEL(title), 0.0f);
gtk_box_append(GTK_BOX(box), title);
GtkWidget* body = gtk_label_new(hint.body);
gtk_label_set_wrap(GTK_LABEL(body), TRUE);
gtk_label_set_max_width_chars(GTK_LABEL(body), 42);
gtk_label_set_xalign(GTK_LABEL(body), 0.0f);
gtk_box_append(GTK_BOX(box), body);
if (hint.shortcut != nullptr && hint.shortcut[0] != '\0') {
const std::string key = std::string("Shortcut: ") + hint.shortcut;
GtkWidget* line = gtk_label_new(key.c_str());
gtk_widget_add_css_class(line, "dim-label");
gtk_label_set_xalign(GTK_LABEL(line), 0.0f);
gtk_box_append(GTK_BOX(box), line);
}
GtkWidget* popover = gtk_popover_new();
gtk_popover_set_child(GTK_POPOVER(popover), box);
return popover;
}
/// One "?" button, marked with its surface's id.
GtkWidget* hint_button(const HelpHint& hint) {
GtkWidget* button = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(button), kHintIcon);
gtk_menu_button_set_popover(GTK_MENU_BUTTON(button), hint_popover(hint));
gtk_widget_add_css_class(button, "flat");
gtk_widget_set_valign(button, GTK_ALIGN_CENTER);
gtk_widget_set_tooltip_text(button, "What is this?");
g_object_set_data_full(G_OBJECT(button), kHintKey, g_strdup(hint.id), g_free);
return button;
}
const HelpHint* hint_by_id(const char* id) {
for (int i = 0; i < kHelpHintCount; ++i) {
if (std::strcmp(kHelpHints[i].id, id) == 0) return &kHelpHints[i];
}
return nullptr;
}
/// Takes the hint off `host`, if it carries one. Dismissed first, so a popover that is still up is
/// not destroyed underneath GTK's own popdown path (a `GtkMenuButton` owns its popover, so the
/// button cannot be dropped while the bubble is showing).
void remove_hint(GtkWidget* host) {
if (host == nullptr) return;
for (GtkWidget* child = gtk_widget_get_first_child(host); child != nullptr;) {
GtkWidget* next = gtk_widget_get_next_sibling(child);
if (g_object_get_data(G_OBJECT(child), kHintKey) != nullptr) {
if (GTK_IS_MENU_BUTTON(child)) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(child));
if (popover != nullptr && gtk_widget_is_visible(GTK_WIDGET(popover))) {
gtk_popover_popdown(popover);
}
}
// `gtk_widget_unparent()` rather than `gtk_box_remove()`: the host may be a box or the plot's
// overlay, and this is the one call that means the same thing for both.
gtk_widget_unparent(child);
}
child = next;
}
}
/// Puts the hint for one table row on `host`, or takes it off when the layer is off.
void sync_one(App& app, int row, GtkWidget* host) {
if (row < 0 || row >= kHelpHintCount || host == nullptr) return;
const HelpHint& hint = kHelpHints[row];
remove_hint(host);
if (!app.settings.contextual_help) return;
GtkWidget* button = hint_button(hint);
if (GTK_IS_BOX(host)) {
gtk_box_append(GTK_BOX(host), button);
} else if (GTK_IS_OVERLAY(host)) {
// The plot's own case: an overlay child in the corner, where the layout editor's buttons are not
// (they follow the rows, and the Done button is the *other* corner).
gtk_widget_set_halign(button, GTK_ALIGN_END);
gtk_widget_set_valign(button, GTK_ALIGN_START);
gtk_widget_set_margin_end(button, 8);
gtk_widget_set_margin_top(button, 8);
gtk_overlay_add_overlay(GTK_OVERLAY(host), button);
} else {
gtk_widget_set_parent(button, host);
}
}
/// Walks up from `widget` counting the hint buttons in the subtree, and collecting their ids.
int count_hints(GtkWidget* widget, std::string* ids) {
if (widget == nullptr) return 0;
int n = 0;
if (const char* id = static_cast<const char*>(g_object_get_data(G_OBJECT(widget), kHintKey))) {
if (ids != nullptr) {
if (!ids->empty()) ids->push_back(' ');
ids->append(id);
}
++n;
}
for (GtkWidget* child = gtk_widget_get_first_child(widget); child != nullptr;
child = gtk_widget_get_next_sibling(child)) {
n += count_hints(child, ids);
}
return n;
}
} // namespace
void context_help_sync(App& app) {
for (int i = 0; i < kHelpHintCount; ++i) sync_one(app, i, app.*(kHelpHints[i].host));
}
void context_help_add(App& app, const char* id, GtkWidget* host) {
const HelpHint* hint = hint_by_id(id);
if (hint == nullptr) return;
sync_one(app, static_cast<int>(hint - kHelpHints), host);
}
int context_help_count(App& app, std::string* ids) {
if (ids != nullptr) ids->clear();
if (app.window == nullptr) return 0;
return count_hints(app.window, ids);
}
void on_contextual_help_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user);
const bool on = gtk_toggle_button_get_active(button) != FALSE;
if (on == app->settings.contextual_help) return;
app->settings.contextual_help = on;
// Through the one mechanism the preferences already use: the settings file, written in full so the
// keys this toggle does not own are carried through from the value the app is running on.
if (!save_settings(app->settings)) {
std::fprintf(stderr, "[help] could not write %s: the toggle will not survive a restart\n",
settings_path().c_str());
}
context_help_sync(*app);
}
} // namespace ui_impl
} // namespace udpscope
+4
View File
@@ -109,6 +109,10 @@ GtkWidget* build_cursors_panel(App& app) {
return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
}
// The panel's heading row -- its first child, which carries the title, the mode dropdown, Reset and
// the pane's switch. It is where the contextual help's "?" joins (see `ui_context_help.cpp`).
app.cursors_head = gtk_widget_get_first_child(box);
// The mode names are the dropdown's model, and they are a C array, so they are set here.
const char* names[5] = {kCursorModeNames[0], kCursorModeNames[1], kCursorModeNames[2],
kCursorModeNames[3], nullptr};
+2
View File
@@ -986,6 +986,8 @@ GtkWidget* build_events_pane(App& app) {
app.events_accumulate = GTK_WIDGET(gtk_builder_get_object(ui, "events_accumulate"));
GtkWidget* live = GTK_WIDGET(gtk_builder_get_object(ui, "events_live"));
app.events_list = GTK_WIDGET(gtk_builder_get_object(ui, "events_list"));
// The heading row, where the contextual help's "?" joins (see `ui_context_help.cpp`).
app.events_head = GTK_WIDGET(gtk_builder_get_object(ui, "events_head"));
if (app.events_revealer == nullptr || app.events_compare == nullptr ||
app.events_accumulate == nullptr || live == nullptr || app.events_list == nullptr) {
// A missing id is a mistake in the `.ui`: the pane comes up empty rather than taking the window with it,
+260 -2
View File
@@ -2962,9 +2962,12 @@ gboolean menus_probe_step(gpointer user) {
if (a->dbg_menus_step == 1) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
GtkWidget* box = popover != nullptr ? gtk_popover_get_child(popover) : nullptr;
static const char* const kExpected[] = {"Fit to data", "Layout editor", "Signal list",
// The popover's child is the stack the menu's two views are pages of; the rows are the visible
// page's. The Help view is measured by the help probe, not here.
if (box != nullptr && GTK_IS_STACK(box)) box = gtk_stack_get_visible_child(GTK_STACK(box));
static const char* const kExpected[] = {"Fit to data", "Layout editor",
"Export visible range...", "Configurations...",
"Keyboard shortcuts", "Help", "About udpscope"};
"Help", "Preferences\u2026", "About udpscope"};
const int expected = static_cast<int>(sizeof(kExpected) / sizeof(kExpected[0]));
int rows = 0;
int bad = 0;
@@ -4479,5 +4482,260 @@ gboolean help_probe(gpointer user) {
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_HELPUI_MS`: the help affordance layer, end to end -- the first-run auto-start, the
/// four entries of the Help submenu, the contextual-help toggle and what it does to the "?"
/// affordances, and the tour started, stepped and dismissed.
///
/// The steps are 300 ms apart because everything it measures is an *allocation*: a widget's is 0 in
/// the tick it is first shown (see the allocation bullets in
/// `docs/claude/invariants-trigger-and-pinning.md`), so a step that read its own predecessor's
/// geometry would read a rectangle from before the frame that placed it.
///
/// The tour is started *from here* rather than from the window, so the probe path is exercised with
/// whatever history the run has; the auto-start is read first, before this touches anything.
void helpui_probe_next(App& a, int step, guint delay_ms) {
a.dbg_helpui_step = step;
g_timeout_add(delay_ms, helpui_probe_step, &a);
}
/// The window's event controllers, and how many of them are clicks. A tour that grabs input and does
/// not give it back shows up here as a count that grew.
void helpui_count_controllers(App* a, int* total, int* clicks) {
*total = 0;
*clicks = 0;
if (a->window == nullptr) return;
GListModel* controllers = gtk_widget_observe_controllers(a->window);
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
for (guint i = 0; i < n; ++i) {
GObject* item = G_OBJECT(g_list_model_get_item(controllers, i));
if (item != nullptr) {
++*total;
if (GTK_IS_GESTURE_CLICK(item)) ++*clicks;
g_object_unref(item);
}
}
if (controllers != nullptr) g_object_unref(controllers);
}
/// The view the menu is showing: the stack's visible child, which is where the rows are.
GtkWidget* helpui_menu_view(App* a) {
if (a->menu_stack == nullptr) return nullptr;
return gtk_stack_get_visible_child(GTK_STACK(a->menu_stack));
}
/// One line: the spotlight, the widget it spotlights, and the card, all in **window coordinates** --
/// the frame a screenshot is cropped in. `contains` is the spotlight covering its target (it is the
/// target's own rectangle, grown three pixels) and `clear` is the card missing the target.
void helpui_report_geometry(App& a, const char* what) {
graphene_rect_t spot = GRAPHENE_RECT_INIT(0, 0, 0, 0);
graphene_rect_t target = GRAPHENE_RECT_INIT(0, 0, 0, 0);
graphene_rect_t card = GRAPHENE_RECT_INIT(0, 0, 0, 0);
GtkWidget* t = tour_target(a);
const bool have_spot =
a.tour_spot != nullptr && gtk_widget_compute_bounds(a.tour_spot, a.window, &spot);
const bool have_target = t != nullptr && gtk_widget_is_visible(t) &&
gtk_widget_compute_bounds(t, a.window, &target);
const bool have_card =
a.tour_card != nullptr && gtk_widget_compute_bounds(a.tour_card, a.window, &card);
const auto contains = [](const graphene_rect_t& outer, const graphene_rect_t& inner) {
return outer.origin.x <= inner.origin.x + 0.5 && outer.origin.y <= inner.origin.y + 0.5 &&
outer.origin.x + outer.size.width >= inner.origin.x + inner.size.width - 0.5 &&
outer.origin.y + outer.size.height >= inner.origin.y + inner.size.height - 0.5;
};
const auto overlaps = [](const graphene_rect_t& p, const graphene_rect_t& q) {
return p.origin.x < q.origin.x + q.size.width && q.origin.x < p.origin.x + p.size.width &&
p.origin.y < q.origin.y + q.size.height && q.origin.y < p.origin.y + p.size.height;
};
std::fprintf(stderr,
"[helpui] %s=%d/%d visible=%d spot=[%.0f,%.0f %.0fx%.0f] "
"target=[%.0f,%.0f %.0fx%.0f] contains=%d card=[%.0f,%.0f %.0fx%.0f] clear=%d\n",
what, tour_step_index(a) + 1, tour_step_count(a), tour_active(a) ? 1 : 0,
spot.origin.x, spot.origin.y, spot.size.width, spot.size.height, target.origin.x,
target.origin.y, target.size.width, target.size.height,
have_spot && have_target && contains(spot, target) ? 1 : 0, card.origin.x,
card.origin.y, card.size.width, card.size.height,
have_card && have_target && !overlaps(card, target) ? 1 : 0);
}
gboolean helpui_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const int step = a->dbg_helpui_step;
if (step == 0) {
// The run's own history decides the auto-start (`settings.tour_seen`), and this reads it before
// anything else touches the tour: a throwaway config directory is a first run, and a second run
// against the same one is not.
std::fprintf(stderr,
"[helpui] auto_started=%d visible=%d steps=%d step=%d (%s)\n",
a->tour_auto_started ? 1 : 0, tour_active(*a) ? 1 : 0, tour_step_count(*a),
tour_step_index(*a),
a->settings.tour_seen ? "tour_seen already set" : "first run");
if (tour_active(*a)) tour_dismiss(*a);
std::string ids;
const int hints = context_help_count(*a, &ids);
std::fprintf(stderr, "[helpui] contextual on: hints=%d ids={%s}\n", hints, ids.c_str());
int total = 0;
int clicks = 0;
helpui_count_controllers(a, &total, &clicks);
a->dbg_helpui_controllers = total;
// Autohide off *for the probe only*, the same as the menu probe: a popover dismisses itself when
// it loses the pointer grab, and under a bare `xvfb-run` there is no window manager to hand it
// one, so the rows would be measured before they were ever mapped.
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
if (popover != nullptr) gtk_popover_set_autohide(popover, FALSE);
gtk_menu_button_popup(GTK_MENU_BUTTON(a->menu_button));
helpui_probe_next(*a, 1, 300);
return G_SOURCE_REMOVE;
}
if (step == 1) {
// The menu's own view first: the door into the Help view has to be on it.
GtkWidget* box = helpui_menu_view(a);
int rows = 0;
int help_row = 0;
for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
row != nullptr; row = gtk_widget_get_next_sibling(row)) {
if (!GTK_IS_BUTTON(row)) continue;
GtkWidget* label = nullptr;
GtkWidget* accel = nullptr;
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
if (label != nullptr && std::strcmp(gtk_label_get_text(GTK_LABEL(label)), "Help") == 0) {
help_row = 1;
}
++rows;
}
std::fprintf(stderr, "[helpui] main view: rows=%d help_row=%d\n", rows, help_row);
// Into the Help view *through the row itself*: the handler is the thing that can break, so the
// probe goes the way a user goes.
if (a->menu_help_row != nullptr) g_signal_emit_by_name(a->menu_help_row, "clicked");
helpui_probe_next(*a, 2, 300);
return G_SOURCE_REMOVE;
}
if (step == 2) {
static const char* const kExpected[] = {"Back", "Keyboard shortcuts", "Documentation",
"Guided tour", "Enable contextual help"};
constexpr int kExpectedCount = static_cast<int>(sizeof(kExpected) / sizeof(kExpected[0]));
GtkWidget* box = helpui_menu_view(a);
int rows = 0;
int bad = 0;
for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
row != nullptr; row = gtk_widget_get_next_sibling(row)) {
if (!GTK_IS_BUTTON(row)) continue;
GtkWidget* label = nullptr;
GtkWidget* accel = nullptr;
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
const char* text = label != nullptr ? gtk_label_get_text(GTK_LABEL(label)) : "";
const char* key = accel != nullptr ? gtk_label_get_text(GTK_LABEL(accel)) : "";
const bool named = rows < kExpectedCount && std::strcmp(text, kExpected[rows]) == 0;
if (!named) ++bad;
std::fprintf(stderr, "[helpui] entry %d '%s' accel='%s' order=%d\n", rows, text, key,
named ? 1 : 0);
++rows;
}
const bool on = a->contextual_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->contextual_toggle)) != FALSE;
std::fprintf(stderr, "[helpui] help view: rows=%d expected=%d rows_bad=%d back=%d toggle=%d "
"entries='%s|%s|%s'\n",
rows, kExpectedCount, bad, rows > 0 ? 1 : 0, on ? 1 : 0,
a->menu_shortcuts_row != nullptr ? "Keyboard shortcuts" : "?",
a->menu_docs_row != nullptr ? "Documentation" : "?",
a->menu_tour_row != nullptr ? "Guided tour" : "?");
// The toggle's *effect*: off means the affordances are gone, not greyed. Both directions are
// driven through the row, because the row is what writes the setting.
if (a->contextual_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->contextual_toggle), FALSE);
}
std::string off_ids;
const int off_hints = context_help_count(*a, &off_ids);
if (a->contextual_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->contextual_toggle), TRUE);
}
std::string on_ids;
const int on_hints = context_help_count(*a, &on_ids);
std::fprintf(stderr, "[helpui] toggle: off -> hints=%d ids={%s}; on -> hints=%d ids={%s}; "
"setting=%s\n",
off_hints, off_ids.c_str(), on_hints, on_ids.c_str(),
a->settings.contextual_help ? "on" : "off");
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
if (popover != nullptr) gtk_popover_set_autohide(popover, TRUE);
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->menu_button));
helpui_probe_next(*a, 3, 300);
return G_SOURCE_REMOVE;
}
if (step == 3) {
start_tour(*a);
std::fprintf(stderr, "[helpui] tour started from the probe: visible=%d steps=%d step=%d\n",
tour_active(*a) ? 1 : 0, tour_step_count(*a), tour_step_index(*a));
helpui_probe_next(*a, 4, 400);
return G_SOURCE_REMOVE;
}
if (step == 4 || step == 5) {
// The spotlight against the widget it spotlights, and the card against the same widget: the
// spotlight must *contain* the target, and the card must not overlap it when the window has room
// for a card beside or above/below it. All of it is an allocation, so it is read one frame after
// the step it describes: `update_tour()` places this frame from the allocation the last one left.
helpui_report_geometry(*a, "step");
if (step == 4) {
// Next, which is what a user presses to walk the tour.
tour_step(*a, 1);
std::fprintf(stderr, "[helpui] after Next: step=%d/%d visible=%d\n", tour_step_index(*a) + 1,
tour_step_count(*a), tour_active(*a) ? 1 : 0);
helpui_probe_next(*a, 5, 400);
return G_SOURCE_REMOVE;
}
// Back, then Skip: the two ways out that are not the last step.
tour_step(*a, -1);
std::fprintf(stderr, "[helpui] after Back: step=%d/%d visible=%d\n", tour_step_index(*a) + 1,
tour_step_count(*a), tour_active(*a) ? 1 : 0);
tour_dismiss(*a);
// Dismissed, and nothing left behind. `controllers=` against the count taken before the tour
// opened is the reading for "no lingering input grab": this overlay is ordinary widgets and
// takes no grab at all, so a count that grew would be a grab somebody added.
int total = 0;
int clicks = 0;
helpui_count_controllers(a, &total, &clicks);
const auto hidden = [](GtkWidget* w) { return w == nullptr || !gtk_widget_is_visible(w); };
std::fprintf(stderr,
"[helpui] after Skip: visible=%d dims_hidden=%d spot_hidden=%d card_hidden=%d "
"controllers=%d (was %d) click_gestures=%d\n",
tour_active(*a) ? 1 : 0,
hidden(a->tour_dim[0]) && hidden(a->tour_dim[1]) && hidden(a->tour_dim[2]) &&
hidden(a->tour_dim[3])
? 1
: 0,
hidden(a->tour_spot) ? 1 : 0, hidden(a->tour_card) ? 1 : 0, total,
a->dbg_helpui_controllers, clicks);
capture_help_state(*a);
std::fprintf(stderr, "[helpui] layer: %s\n", a->dbg_context_help.c_str());
helpui_probe_next(*a, 6, 300);
return G_SOURCE_REMOVE;
}
if (step == 6 || step == 8) {
// The trigger pane's summary grid is on screen only in *locked* mode, and one of the help's
// figures is that content: the probe reads `help figures:` in both states, so the geometry agent
// has the locked pane's numbers without having to guess how to get there.
if (a->trigger_edit != nullptr) g_signal_emit_by_name(a->trigger_edit, "clicked");
helpui_probe_next(*a, step == 6 ? 7 : 9, 400);
return G_SOURCE_REMOVE;
}
// Step 7 (locked) and step 9 (back in configuration mode): one capture each, so each line is the
// state it names.
capture_help_state(*a);
std::fprintf(stderr, "[helpui] figures %s: %s\n", step == 7 ? "locked" : "config",
a->dbg_help_figures.c_str());
if (step == 7) {
helpui_probe_next(*a, 8, 400);
return G_SOURCE_REMOVE;
}
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
} // namespace ui_impl
} // namespace udpscope
+2
View File
@@ -592,6 +592,8 @@ GtkWidget* build_signal_panel(App& app) {
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/signals-panel.ui");
GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "signal_panel"));
app.signal_list = GTK_WIDGET(gtk_builder_get_object(ui, "signal_list"));
// The heading row, where the contextual help's "?" joins (see `ui_context_help.cpp`).
app.signals_head = GTK_WIDGET(gtk_builder_get_object(ui, "signals_head"));
app.signals_edit = GTK_WIDGET(gtk_builder_get_object(ui, "signals_edit"));
if (app.signals_edit != nullptr) {
g_signal_connect(app.signals_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app);
+417
View File
@@ -0,0 +1,417 @@
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari
//
// The guided tour: a walk over the surfaces a first run has no way of knowing about, drawn on a
// `GtkOverlay` inserted above the window's content.
//
// Why an overlay and not a popover per step: a popover takes a pointer grab, and a run of them takes
// one per step. The overlay takes none at all -- what makes the tour modal is the four dim
// rectangles, which are ordinary widgets that swallow the clicks that land on them, and dismissing
// the tour is hiding them. There is no grab to leak and nothing to unparent.
//
// The spotlight is *four* rectangles and not a drawing: the top, bottom, left and right strips
// around the target, positioned by margins each frame from `gtk_widget_compute_bounds()`. That is
// the whole of "dims everything except the target", it needs no cairo and no paint function, and it
// is re-measured on every frame -- the target's own allocation is 0 on the frame it is first shown
// (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`), and a window
// resize moves it under a spotlight that a one-shot measurement would leave behind.
#include "ui_app.hpp"
#include <gtk/gtk.h>
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <string>
namespace udpscope {
namespace ui_impl {
namespace {
/// One step. The target is measured, never named by a stored rectangle: the point of re-measuring
/// every frame is that nothing here knows where the widget is.
struct TourStep {
const char* id;
const char* title;
const char* body;
GtkWidget* App::* target;
};
constexpr TourStep kTourSteps[] = {
{"plot", "The plot",
"Every signal you have switched on is drawn here, all rows sharing one time axis. The wheel "
"zooms, a drag pans, and a right-click on a row opens that plot's own menu.",
&App::area_overlay},
{"side", "The side panel",
"The working surface beside the plot. Measure holds the cursors and the measurements; Signals "
"holds the trigger and the list of signals. The buttons in the bar above show and hide it.",
&App::side_box},
{"trigger", "The trigger",
"Describe an event -- a signal, a condition, a level and the window around it -- and arm it: "
"the view then freezes on the first event that matches, however rare it is.",
&App::trigger_body},
{"signals", "The signals",
"Every signal the source declares, each with the switch that acquires it. A signal has to be "
"switched on before it can be plotted, measured or searched.",
&App::signal_list},
{"status", "The status line",
"The stream's own report, once a second: the display mode, the rate arriving, the window on "
"screen and the buffer holding it. The information button opens the counters behind it.",
&App::status_row},
};
constexpr int kTourStepCount = static_cast<int>(sizeof(kTourSteps) / sizeof(kTourSteps[0]));
/// The card's width. Wide enough for a comfortable line of the paragraph, narrow enough to fit
/// beside the plot on the documented window -- at 756 px the side panel is 320 and a card wider than
/// that is a card that has to cover its own target. It is a *floor* on the box, not a cap on the
/// paragraph: the wrapped label is capped by `max_width_chars` so the natural width never exceeds it.
constexpr int kCardWidth = 280;
/// The gap between the hole and whatever is placed beside it. Eight and not twelve: the side panel
/// is 320 px, and it has to hold the card and both gaps.
constexpr int kGap = 8;
/// Places an overlay child of the tour overlay at an exact rectangle. `GtkOverlay` positions an
/// overlay child by its alignment and its margins and gives it its measured size, so a fixed
/// rectangle is a size request plus two margins.
void place(GtkWidget* widget, int x, int y, int width, int height) {
gtk_widget_set_halign(widget, GTK_ALIGN_START);
gtk_widget_set_valign(widget, GTK_ALIGN_START);
gtk_widget_set_margin_start(widget, std::max(0, x));
gtk_widget_set_margin_top(widget, std::max(0, y));
gtk_widget_set_size_request(widget, std::max(0, width), std::max(0, height));
}
/// Places an overlay child by its *natural* size at a point. The card sizes itself -- its paragraph
/// wraps at a stated width -- and only its position is ours. Giving it the width we measured back as
/// a size request is a feedback loop: a request is a floor on the widget's own measure, so the width
/// it was placed at becomes the width it reports next frame, and a starting value that is one pixel
/// too large grows by that pixel for ever.
void place_natural(GtkWidget* widget, int x, int y) {
gtk_widget_set_halign(widget, GTK_ALIGN_START);
gtk_widget_set_valign(widget, GTK_ALIGN_START);
gtk_widget_set_margin_start(widget, std::max(0, x));
gtk_widget_set_margin_top(widget, std::max(0, y));
}
/// The step currently walked, or nullptr when the tour is not on a step.
const TourStep* current_step(const App& app) {
if (app.tour_index < 0 || app.tour_index >= static_cast<int>(app.tour_plan.size())) return nullptr;
return &kTourSteps[app.tour_plan[static_cast<std::size_t>(app.tour_index)]];
}
/// Restates the card's text for the current step. Called on a change of step, not per frame.
void show_step(App& app) {
const TourStep* step = current_step(app);
if (step == nullptr || app.tour_card == nullptr) return;
// The hole belongs to the step: reusing the last one for a frame would put the spotlight on the
// surface the tour has just left, and the next frame fills this one in.
app.tour_hole[0] = app.tour_hole[1] = app.tour_hole[2] = app.tour_hole[3] = 0;
char markup[128];
std::snprintf(markup, sizeof(markup), "<b>%s</b>", step->title);
gtk_label_set_markup(GTK_LABEL(app.tour_title), markup);
gtk_label_set_text(GTK_LABEL(app.tour_body), step->body);
char counter[64];
std::snprintf(counter, sizeof(counter), "%d of %d",
app.tour_index + 1, static_cast<int>(app.tour_plan.size()));
gtk_label_set_text(GTK_LABEL(app.tour_counter), counter);
gtk_widget_set_visible(app.tour_back, app.tour_index > 0);
gtk_button_set_label(GTK_BUTTON(app.tour_next),
app.tour_index + 1 >= static_cast<int>(app.tour_plan.size()) ? "Finish"
: "Next");
}
} // namespace
GtkWidget* tour_target(App& app) {
const TourStep* step = current_step(app);
return step != nullptr ? app.*(step->target) : nullptr;
}
void build_tour(App& app) {
if (app.tour_overlay == nullptr) return;
for (GtkWidget*& dim : app.tour_dim) {
dim = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(dim, "udpscope-tour-dim");
gtk_overlay_add_overlay(GTK_OVERLAY(app.tour_overlay), dim);
}
app.tour_spot = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(app.tour_spot, "udpscope-tour-spot");
// The outline is a frame, not a shield: a click on the *target* is a click on the target.
gtk_widget_set_can_target(app.tour_spot, FALSE);
gtk_overlay_add_overlay(GTK_OVERLAY(app.tour_overlay), app.tour_spot);
// The card, last: overlay children are painted in the order they were added, and the card has to
// be above the dim strips that cover the rest of the window.
app.tour_card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_add_css_class(app.tour_card, "udpscope-tour-card");
gtk_widget_set_size_request(app.tour_card, kCardWidth, -1);
app.tour_title = gtk_label_new(nullptr);
gtk_label_set_xalign(GTK_LABEL(app.tour_title), 0.0f);
gtk_box_append(GTK_BOX(app.tour_card), app.tour_title);
app.tour_body = gtk_label_new(nullptr);
gtk_label_set_wrap(GTK_LABEL(app.tour_body), TRUE);
// The cap that keeps the card narrow. `max_width_chars` is what bounds a wrapped label's *natural*
// width; without it the paragraph's natural width is the whole sentence on one line and the card
// is that wide however it is placed.
gtk_label_set_max_width_chars(GTK_LABEL(app.tour_body), 30);
gtk_label_set_xalign(GTK_LABEL(app.tour_body), 0.0f);
gtk_box_append(GTK_BOX(app.tour_card), app.tour_body);
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
app.tour_counter = gtk_label_new(nullptr);
gtk_widget_add_css_class(app.tour_counter, "dim-label");
gtk_label_set_xalign(GTK_LABEL(app.tour_counter), 0.0f);
gtk_widget_set_hexpand(app.tour_counter, TRUE);
gtk_box_append(GTK_BOX(footer), app.tour_counter);
app.tour_back = gtk_button_new_with_label("Back");
g_signal_connect(app.tour_back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
tour_step(*static_cast<App*>(user), -1);
}),
&app);
gtk_box_append(GTK_BOX(footer), app.tour_back);
GtkWidget* skip = gtk_button_new_with_label("Skip");
g_signal_connect(skip, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
tour_dismiss(*static_cast<App*>(user));
}),
&app);
gtk_box_append(GTK_BOX(footer), skip);
app.tour_next = gtk_button_new_with_label("Next");
gtk_widget_add_css_class(app.tour_next, "suggested-action");
g_signal_connect(app.tour_next, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
tour_step(*static_cast<App*>(user), 1);
}),
&app);
gtk_box_append(GTK_BOX(footer), app.tour_next);
gtk_box_append(GTK_BOX(app.tour_card), footer);
gtk_overlay_add_overlay(GTK_OVERLAY(app.tour_overlay), app.tour_card);
// Everything starts hidden: the tour is not on screen until it is started.
for (GtkWidget* dim : app.tour_dim) gtk_widget_set_visible(dim, FALSE);
gtk_widget_set_visible(app.tour_spot, FALSE);
gtk_widget_set_visible(app.tour_card, FALSE);
}
void start_tour(App& app) {
if (app.tour_overlay == nullptr || app.tour_card == nullptr) return;
// "The tour has run" is written when it *starts*: a user who skips it has still seen it, and the
// alternative -- marking it on the last step -- would offer the whole tour again to everyone who
// decided in the first second that they did not want it.
if (!app.settings.tour_seen) {
app.settings.tour_seen = true;
(void)save_settings(app.settings);
}
// The steps whose target is on screen, in table order. A surface that is not there is not walked
// over -- the point of asking `is_visible()` rather than `get_visible()` is that a widget under a
// hidden parent still reports its own flag as true.
app.tour_plan.clear();
for (int i = 0; i < kTourStepCount; ++i) {
GtkWidget* target = app.*(kTourSteps[i].target);
if (target != nullptr && gtk_widget_is_visible(target)) app.tour_plan.push_back(i);
}
if (app.tour_plan.empty()) return;
app.tour_index = 0;
app.tour_running = true;
for (GtkWidget* dim : app.tour_dim) gtk_widget_set_visible(dim, TRUE);
gtk_widget_set_visible(app.tour_spot, TRUE);
gtk_widget_set_visible(app.tour_card, TRUE);
show_step(app);
update_tour(app);
}
void tour_step(App& app, int delta) {
if (!app.tour_running) return;
const int next = app.tour_index + delta;
if (next < 0 || next >= static_cast<int>(app.tour_plan.size())) {
tour_dismiss(app);
return;
}
app.tour_index = next;
show_step(app);
update_tour(app);
}
void tour_dismiss(App& app) {
app.tour_running = false;
app.tour_index = 0;
for (GtkWidget* dim : app.tour_dim) {
if (dim != nullptr) gtk_widget_set_visible(dim, FALSE);
}
if (app.tour_spot != nullptr) gtk_widget_set_visible(app.tour_spot, FALSE);
if (app.tour_card != nullptr) gtk_widget_set_visible(app.tour_card, FALSE);
}
bool tour_active(const App& app) { return app.tour_running; }
int tour_step_count(const App& app) { return static_cast<int>(app.tour_plan.size()); }
int tour_step_index(const App& app) { return app.tour_index; }
void update_tour(App& app) {
if (!app.tour_running || app.tour_overlay == nullptr) return;
const int w = gtk_widget_get_width(app.tour_overlay);
const int h = gtk_widget_get_height(app.tour_overlay);
if (w <= 0 || h <= 0) return; // no allocation yet: nothing to place anything against
// The target, re-measured every frame. Its allocation is 0 on the frame it is first shown, so a
// one-shot measurement would leave the hole in the corner.
GtkWidget* target = tour_target(app);
graphene_rect_t bounds = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
const bool target_present = target != nullptr && gtk_widget_is_visible(target);
if (target_present && gtk_widget_compute_bounds(target, app.tour_overlay, &bounds)) {
// The hole: the target, grown by three pixels so the outline sits *around* the widget rather than
// on it, and clamped to the overlay -- a target partly off screen is an ordinary case (a sidebar
// dragged wide), and a negative rectangle is not something a margin can express.
const int x0 = static_cast<int>(std::floor(bounds.origin.x)) - 3;
const int y0 = static_cast<int>(std::floor(bounds.origin.y)) - 3;
const int x1 = static_cast<int>(std::ceil(bounds.origin.x + bounds.size.width)) + 3;
const int y1 = static_cast<int>(std::ceil(bounds.origin.y + bounds.size.height)) + 3;
app.tour_hole[0] = std::clamp(x0, 0, w);
app.tour_hole[1] = std::clamp(y0, 0, h);
app.tour_hole[2] = std::clamp(x1, 0, w) - app.tour_hole[0];
app.tour_hole[3] = std::clamp(y1, 0, h) - app.tour_hole[1];
} else if (!target_present) {
// Nothing on screen to point at: the whole surface dims and the card takes a corner. A step whose
// target went away mid-tour is not a step to be skipped silently.
app.tour_hole[0] = app.tour_hole[1] = app.tour_hole[2] = app.tour_hole[3] = 0;
}
// ... and when the target *is* there but could not be measured, the previous hole stands: a frame
// can arrive between two layout passes, and emptying the hole there is the spotlight and the card
// jumping to the corner and back.
const int hx = app.tour_hole[0];
const int hy = app.tour_hole[1];
const int hw = app.tour_hole[2];
const int hh = app.tour_hole[3];
place(app.tour_dim[0], 0, 0, w, hy); // above the hole
place(app.tour_dim[1], 0, hy + hh, w, h - hy - hh); // below it
place(app.tour_dim[2], 0, hy, hx, hh); // to its left
place(app.tour_dim[3], hx + hw, hy, w - hx - hw, hh); // to its right
place(app.tour_spot, hx, hy, hw, hh);
// The card, placed clear of the hole whenever the window has room for it. The strip order is the
// order a card is easiest to read in: under the thing it describes, then over it, then in
// whichever column is beside it -- which is, on this window, the side panel for the plot's step and
// the plot for every step in the side panel.
//
// Its size is measured, never requested -- but `gtk_widget_measure()` reports the size *including
// the widget's own margins* (measured: a 280 px box with `margin-start` 453 measures 733). Ours are
// the card's position, so reading the measure without taking them off is a feedback loop: the width
// it was placed at becomes the width it reports, and the card walks to the far corner and back
// between frames.
int cw = 0;
int ch = 0;
gtk_widget_measure(app.tour_card, GTK_ORIENTATION_HORIZONTAL, -1, nullptr, &cw, nullptr, nullptr);
cw = std::max(kCardWidth, cw - gtk_widget_get_margin_start(app.tour_card) -
gtk_widget_get_margin_end(app.tour_card));
gtk_widget_measure(app.tour_card, GTK_ORIENTATION_VERTICAL, cw, nullptr, &ch, nullptr, nullptr);
ch = std::max(0, ch - gtk_widget_get_margin_top(app.tour_card) -
gtk_widget_get_margin_bottom(app.tour_card));
int cx = 0, cy = 0;
if (h - (hy + hh) >= ch + 2 * kGap) {
cx = std::clamp(hx, kGap, std::max(kGap, w - cw - kGap));
cy = hy + hh + kGap;
} else if (hy >= ch + 2 * kGap) {
cx = std::clamp(hx, kGap, std::max(kGap, w - cw - kGap));
cy = hy - ch - kGap;
} else if (w - (hx + hw) >= cw + 2 * kGap) {
cx = hx + hw + kGap;
cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap));
} else if (hx >= cw + 2 * kGap) {
cx = hx - cw - kGap;
cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap));
} else {
// No clear side at all -- the target is nearly the whole window, which is the plot's step in a
// window narrow enough for the split view to *overlay* the side panel rather than put it beside
// the plot. The card goes to the bottom-right corner, which is where the least of the target is,
// and the two readings the probe prints (`spot=`, `card=`) are what show how little choice there
// was rather than the card having been placed badly.
cx = std::max(kGap, w - cw - kGap);
cy = std::max(kGap, h - ch - kGap);
}
place_natural(app.tour_card, cx, cy);
}
gboolean tour_autostart(gpointer user) {
App* app = static_cast<App*>(user);
if (app->window == nullptr || app->tour_overlay == nullptr) return G_SOURCE_REMOVE;
app->tour_auto_started = true;
start_tour(*app);
return G_SOURCE_REMOVE;
}
// --- the dump's readings ----------------------------------------------------
namespace {
/// The surfaces the help's annotated figures label. Their geometry is what a figure's crop box is
/// drawn from, and it has to be *measured* -- at 1280x800 the panels are not where a plausible guess
/// puts them, and a box drawn from a guess is a box over the wrong thing.
struct FigureSurface {
const char* name;
GtkWidget* App::* widget;
};
constexpr FigureSurface kFigureSurfaces[] = {
{"trigger_head", &App::trigger_head},
{"trigger_summary", &App::trigger_summary},
{"trigger_panel", &App::trigger_panel},
{"signal_list", &App::signal_list},
{"side_switcher", &App::side_switcher},
{"side_stack", &App::side_stack},
{"window_button", &App::window_button},
{"status_label", &App::status_label},
{"header_bar", &App::toolbar},
{"plot", &App::area_overlay},
};
constexpr int kFigureSurfaceCount =
static_cast<int>(sizeof(kFigureSurfaces) / sizeof(kFigureSurfaces[0]));
} // namespace
void capture_help_state(App& app) {
std::string ids;
const int hints = context_help_count(app, &ids);
// The icon is read back through the theme for the reason the pane [edit] buttons are: a name
// Adwaita does not have is a *blank* control that works and reads as nothing at all.
GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default());
const bool icon_ok = theme != nullptr && gtk_icon_theme_has_icon(theme, "dialog-question-symbolic");
char layer[512];
std::snprintf(layer, sizeof(layer),
"contextual=%s hints=%d ids={%s} icon=dialog-question-symbolic(%s); tour=%s steps=%d "
"step=%d auto=%d",
app.settings.contextual_help ? "on" : "off", hints, ids.empty() ? "" : ids.c_str(),
icon_ok ? "ok" : "MISSING", app.tour_running ? "running" : "idle",
tour_step_count(app), tour_step_index(app), app.tour_auto_started ? 1 : 0);
app.dbg_context_help = layer;
app.dbg_tour_steps = tour_step_count(app);
app.dbg_tour_step = tour_step_index(app);
app.dbg_tour_visible = app.tour_running ? 1 : 0;
std::string figures;
for (int i = 0; i < kFigureSurfaceCount; ++i) {
const FigureSurface& surface = kFigureSurfaces[i];
GtkWidget* widget = app.*(surface.widget);
graphene_rect_t r = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
int x = -1, y = -1, width = 0, height = 0;
// `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the 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, which a crop box would take for a real rectangle.
if (widget != nullptr && gtk_widget_is_visible(widget) &&
gtk_widget_compute_bounds(widget, app.window, &r)) {
x = static_cast<int>(std::lround(r.origin.x));
y = static_cast<int>(std::lround(r.origin.y));
width = static_cast<int>(std::lround(r.size.width));
height = static_cast<int>(std::lround(r.size.height));
}
if (!figures.empty()) figures.push_back(' ');
figures += std::string(surface.name) + "=" + std::to_string(x) + "," + std::to_string(y) + "," +
std::to_string(width) + "," + std::to_string(height);
}
app.dbg_help_figures = figures;
}
} // namespace ui_impl
} // namespace udpscope
+4
View File
@@ -671,6 +671,10 @@ GtkWidget* build_trigger_panel(App& app) {
gtk_widget_set_margin_top(head, 8);
gtk_widget_set_margin_start(head, 8);
gtk_widget_set_margin_end(head, 8);
// The row the contextual help's "?" for this pane joins (see `ui_context_help.cpp`): a sibling of
// the title and the controls, so a help button can never cover one of them. It is also one of the
// surfaces the help's annotated figures label, which is why the pointer outlives this function.
app.trigger_head = head;
GtkWidget* title = gtk_label_new("Trigger");
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_widget_set_hexpand(title, TRUE);
+9 -1
View File
@@ -53,7 +53,15 @@ void install_css() {
".udpscope-event-title { font-weight: bold; }\n"
".udpscope-event-thumb { border: 1px solid alpha(currentColor, 0.15); border-radius: 4px; }\n"
"row.udpscope-compact button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n"
"row.udpscope-compact checkbutton { min-height: 20px; }\n";
"row.udpscope-compact checkbutton { min-height: 20px; }\n"
// The guided tour. The dim is a half-transparent black rather than a theme colour: its whole
// job is to darken whatever is underneath, on a light theme and a dark one alike. The card
// needs its own background, or a box in a popover-less overlay is transparent and the text
// under it shows through.
".udpscope-tour-dim { background-color: rgba(0, 0, 0, 0.45); }\n"
".udpscope-tour-spot { border: 2px solid @accent_bg_color; border-radius: 8px; }\n"
".udpscope-tour-card { background-color: @window_bg_color; border: 1px solid @borders;"
" border-radius: 12px; padding: 12px; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.45); }\n";
GtkCssProvider* p = gtk_css_provider_new();
gtk_css_provider_load_from_string(p, kCss);
gtk_style_context_add_provider_for_display(gdk_display_get_default(),