fix: the guided tour selects the page a stop's target lives on

A stop whose target is on another page of a stack was entered without
switching to that page, so the spotlight aimed at a widget that was not
on screen. A stop now declares the page it stands on (`TourStop::page`)
and the tour selects it as the step is entered -- the window's trigger
and signals stops put the sidebar on Signals, the chooser's tabs stop
shows the Waveforms page and stepping off it brings Sources back.

The stop filter moved into `tour_begin()` and now runs on
`gtk_widget_get_mapped()`: GTK 4.24's `is_visible()` ignores
`child_visible`, so it kept a stop on another page *and* one in a
collapsed pane alike. A wrong page is kept and selected; a collapsed
pane, whose stack is unmapped too, is still dropped.
This commit is contained in:
Martino Ferrari committed 2026-10-14 23:01:43 +02:00
1 parent 829176248a
commit d549223889
9 files changed
+384 -88

No files matched your search

+18 -10
View File
@@ -924,20 +924,28 @@ the window's own `menu_button()`/`menu_toggle()` -- the same shape, and the same
stylesheet, whose rules `choose_source()` installs before building them since the app installs them stylesheet, whose rules `choose_source()` installs before building them since the app installs them
later. *Documentation* opens the manual directly (it is another process and needs nothing of the app), later. *Documentation* opens the manual directly (it is another process and needs nothing of the app),
*Enable contextual help* writes the settings file the app reads a moment later, and *Guided tour* *Enable contextual help* writes the settings file the app reads a moment later, and *Guided tour*
cannot start anything -- the tour is drawn on the app's window, which does not exist yet -- so it sets starts **the chooser's own walk** -- the surfaces a first run meets first are this dialog's own --
**`Config::tour_pending`**, the value `main()` owns and hands to both the chooser and the front end whose last step sets **`Config::tour_pending`**, the value `main()` owns and hands to both the chooser
(`open_waveform` crosses the same way). Half a second and a half after the window is up, the same and the front end (`open_waveform` crosses the same way). Half a second and a half after the window is
delay the first-run auto-start uses, the pending request starts the tour; it sets `tour_requested` up, the same delay the first-run auto-start uses, the pending request starts the window's tour; it sets
rather than `tour_auto_started`, so the readings say which of the two it was. The chooser's rows show `tour_requested` rather than `tour_auto_started`, so the readings say which of the two it was. The
**no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds them, and this dialog chooser's rows show **no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds
binds none. them, and this dialog binds none.
**The guided tour is five steps on a `GtkOverlay`** inserted above the window's content: the plot, **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 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 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 and either side of the target -- that swallow the clicks that land on them. A stop declares the
`gtk_widget_is_visible()` decides which steps exist at all (a widget under a hidden parent keeps its **page it stands on** (the trigger and the signals steps live on the sidebar's *Signals* page, named
own flag true, so `get_visible()` would have walked over surfaces that are not there). in `TourStop::page`), and the tour selects that page as the step is entered -- on the frame *after*
the switch, since a stack allocates a new page's widgets a frame late -- so the spotlight lands on a
widget the user had left behind on the other tab instead of on nothing. `gtk_widget_get_mapped()`
decides which steps exist at all: a stop on the *wrong page* is kept, because selecting its page
reveals it, while one whose *pane is collapsed* is dropped, because the stack is unmapped with it
(a hidden page keeps its own *visible* flag true -- the widget's own flag, which is all
`is_visible()` walks -- so the old test walked over surfaces that were not on screen). The chooser's
tour works the same way over its own two pages: its tabs step shows the *Waveforms* page and
stepping off it -- in either direction -- brings the *Sources* page back.
Everything is placed from `gtk_widget_compute_bounds()`, re-read on **every frame**, because a 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 widget's allocation is 0 on the frame it is first shown and a window resize moves it under a
+23 -1
View File
@@ -587,6 +587,28 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
the view's `on_done`; the window's passes null, the chooser's sets `Config::tour_pending`, which is the view's `on_done`; the window's passes null, the chooser's sets `Config::tour_pending`, which is
how the two halves are one tour. `App` carries its own `TourView` (`app.tour`), so the machinery is how the two halves are one tour. `App` carries its own `TourView` (`app.tour`), so the machinery is
compiled once and the probe reads either window's state by the same fields. compiled once and the probe reads either window's state by the same fields.
- **A stop declares the page it stands on, and the tour selects that page as the stop is entered.** A
stop whose target lives on another page of a stack -- the window's `signals` (and `trigger`) stops
on the sidebar's `signals` page, the chooser's "Two kinds of input" stop on its `waveforms` page --
names that page in `TourStop::page`, and `show_step()` selects it from `TourView::page_stack`
before anything is measured. The page switch is **not** followed by a one-shot measure: `show_step()`
selects the page and `tour_update()`, which already runs every frame, fills the spotlight in on the
frame *after*, when the new page has been laid out. A stack allocates the new page's widgets on the
frame after the switch, so a measurement taken in the switch's own tick reads zero (the allocation
bullet in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)) -- and reading the
page *name* back is fine immediately, because that is state and not geometry.
- **The stop filter runs on `get_mapped()`, not `is_visible()`, and that is what separates a wrong
page from a collapsed pane.** GTK 4.24's `gtk_widget_is_visible()` walks only the widgets' own
`visible` flags up the parents; it does **not** consult `child_visible`. Both a `GtkStack`'s
non-current pages and `AdwOverlaySplitView`'s hidden sidebar are hidden with
`gtk_widget_set_child_visible()`, so a widget on another page, *and* one in a collapsed pane, both
read `is_visible() == TRUE` (verified against 4.24.1) -- the old filter kept a stop it meant to
drop and left the spotlight on a target with no allocation. `stop_is_walkable()` keeps a stop when
its target is **mapped**, or when it names a page and the stack that page belongs to is mapped: a
wrong page leaves the stack mapped, so the stop is kept and the page switch reveals it; a collapsed
pane leaves the stack unmapped too, so no page can reveal it and the stop is dropped. The filter
lives in `tour_begin()`, not in each window's `start_tour()`, both so the two windows cannot drift
into two rules and because the rule needs the view's own `page_stack`.
- **The chooser's tour needs an overlay over its *whole* window, and `AdwWindow`'s content is - **The chooser's tour needs an overlay over its *whole* window, and `AdwWindow`'s content is
replaced, not wrapped.** Two of the chooser's stops name header-bar buttons, and an overlay inside replaced, not wrapped.** Two of the chooser's stops name header-bar buttons, and an overlay inside
the `AdwToolbarView`'s `content` would give them negative coordinates and clamp the hole to the top the `AdwToolbarView`'s `content` would give them negative coordinates and clamp the hole to the top
@@ -605,7 +627,7 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
window uses and re-synced by its Help menu's toggle. window uses and re-synced by its Help menu's toggle.
- **The hole is kept from the last frame that could measure the target.** `gtk_widget_compute_bounds()` - **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 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 made the spotlight and the card jump to the corner and back; a *target that is not mapped* is the
different case (a genuinely absent surface) and is what empties it. The placement is also 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 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. is first shown, and a window resize moves it under a one-shot measurement.
+61 -20
View File
@@ -516,17 +516,28 @@ Two lines of the self-check output are assertions in disguise:
- `UDPSCOPE_PROBE_CHOOSER_TOUR=1` emits `::clicked` on the help menu's **Guided tour** row, which - `UDPSCOPE_PROBE_CHOOSER_TOUR=1` emits `::clicked` on the help menu's **Guided tour** row, which
now starts *this* walk rather than only requesting the window's: `[sources] chooser help: tour now starts *this* walk rather than only requesting the window's: `[sources] chooser help: tour
started in the chooser`, then `[sources] chooser tour: visible=1 steps=5 step=0`, then a walking started in the chooser`, then `[sources] chooser tour: visible=1 steps=5 step=0`, then
`[sources] chooser tour: next -> step=N/5` per press, then `[sources] chooser tour: finished 5 `[sources] chooser tour: finished 5 stops -- the window continues it (tour_pending=1)` and
stops -- the window continues it (tour_pending=1)`. **`tour_pending=1` and `visible=0` is the `[sources] chooser tour: after finish visible=0 tour_pending=1`. **`tour_pending=1` and
handoff assertion** — the walk is over here and the request rides on the one `Config` value `visible=0` is the handoff assertion** — the walk is over here and the request rides on the one
`main()` hands to both windows. `Config` value `main()` hands to both windows. The walk is driven in one tick (it is the handoff
- `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=<ms>` schedules the same walk on a timer, for reading a tour being asserted, not the geometry), so the pages it selects are not readable here; the timed
**the run itself started** (the first-run auto-start) rather than one the probe started; it must `_MS` form below walks a stop per tick and reads them.
be later than 1500 ms. Paired with `UDPSCOPE_PROBE_PICK_MS` a few hundred ms later, the run then - `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=<ms>` walks the tour a stop per **tick** (250 ms), the cadence
shows the other half: the application's `help layer:` reads `tour=running … auto=0 requested=1`, the window's own probe uses and for its reason: a page a stop selects is allocated on the frame
the window's tour having been started by `tour_start_requested()` rather than by the first-run after the switch. It starts the walk itself when one is not already running, so it does not
timer. depend on the config directory being a first run. Every tick prints the page the stack shows and
the page the stop *stands on*:
`[sources] chooser tour: step=N/5 page=<shown> stop_page=<declared> target_mapped=D`. **The
"Two kinds of input" stop stands on `waveforms`**: `step=2/5 page=waveforms stop_page=waveforms`
is the switch. Then it steps **Back** off that stop and reads the page again —
`[sources] chooser tour: back off the waveforms stop: step=1/5 page=sources` — which is the other
direction, and the previous stop stands on `sources`. It then walks to the end:
`[sources] chooser tour: finished 5 stops -- the window continues it (tour_pending=1)` and
`after finish visible=0 page=sources tour_pending=1`. Paired with `UDPSCOPE_PROBE_PICK_MS` after
the walk, the run shows the other half: the application's `help layer:` reads `tour=running …
auto=0 requested=1`, the window's tour having been started by `tour_start_requested()` rather
than by the first-run timer.
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once - **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
with a drop and a window change, then again with no interaction: the second run's dump with a drop and a window change, then again with no interaction: the second run's dump
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
@@ -766,15 +777,19 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
removing them. The count comes back to the same eleven, which is the other half — a toggle that removing them. The count comes back to the same eleven, which is the other half — a toggle that
removed and could not restore is a one-way switch. 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 - `[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 `Ctrl+T` starts it. `steps=5` is the plan: the table's five steps whose surface is on screen or
(`gtk_widget_is_visible()`, so a collapsed pane is not walked over). selectable into it (`gtk_widget_get_mapped()` -- a wrong *page* is kept, because selecting its
- `[helpui] step=1/5 visible=1 spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` — **in window page reveals it, while a *collapsed pane* is still dropped; see the surface bullets in
coordinates**, the frame a screenshot is cropped in. **`contains` must be 1** on every step: the [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)).
spotlight is the target's own rectangle grown three pixels. `clear` is the card missing the target, - `[helpui] step=1/5 visible=1 side_tab=signals spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C`
and it is 1 wherever the window has room for the card beside or above/below the hole — at the — **in window coordinates**, the frame a screenshot is cropped in, with the sidebar's page beside
documented run's window (see `help figures:` below) the plot's step is *the whole content*, because it (a stop the walk had to change the page for is readable as such; see `[toursurface]` below).
at 756 px the split view overlays the side panel rather than putting it beside the plot, so **`contains` must be 1** on every step: the spotlight is the target's own rectangle grown three
`clear=0` there is the geometry and not a placement bug. The side panel's step reads `clear=1`. 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 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) `[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 click_gestures=2` — the three controls. **`controllers=` equal to `(was N)` is the "no lingering
@@ -789,6 +804,32 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
rather than leaving them to find the mode. `trigger_panel`'s height differs between them (508 px in 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 configuration mode against 72 locked — the body is replaced by the summary), which is the check
that the mode really moved. that the mode really moved.
- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) — a stop that stands on a page it is
not on, entered from the page it is not on. It puts the sidebar on **Measure**, starts the window's
tour, and walks it a stop per tick (400 ms), because a stack allocates the new page's widgets on
the frame *after* the switch — so each reading is of the page the step left standing, not of the
one the switch has just left behind. The lines:
- `[toursurface] before: side_tab=signals signal_list_mapped=1 running=1` — the state a first run
starts in (a fresh config's sidebar is on Signals), then the auto-started walk is dismissed.
- `[toursurface] on measure: side_tab=measure signal_list_mapped=0 side_stack_mapped=1` — a frame
after the switch: **`signal_list_mapped=0` beside `side_stack_mapped=1`** is the whole premise,
the list is genuinely off screen while the stack that holds it is on screen.
- `[toursurface] tour: visible=1 steps=5 step=0 signals_stop=1` — **`signals_stop=1` is the filter's
reading**: a stop whose target is *not mapped* is still in the plan because it names a page the
stack can select. `steps=5` is the full table.
- `[toursurface] step=N/5 side_tab=<page> target_mapped=D[ target=signal_list]` — one per stop. The
page moves from `measure` to `signals` at the `trigger` stop (the first stop whose target is on
the Signals page) and stays there; **the `signals` stop reads `target_mapped=1`**.
- `[toursurface] signals stop=4/5 visible=1 side_tab=signals spot=[…] target=[…] contains=1
card=[…] clear=1` — the `tour_geometry_line()` the helpui probe uses, read on the frame *after*
the switch: **`side_tab=signals` with `contains=1` is the assertion** that the spotlight and the
card are on the list, not on the page the walk came from.
- `[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0 side_stop=0` — the other half
of the filter, read: with `sidebar_wanted` off (`update_side_visibility()` re-asserts it, so this
is how a probe puts the sidebar away for good) the split view unmaps the column, the stack with
it, and the two stops that need the sidebar are dropped while the page-less ones stay. This is
what says a *wrong page* and a *collapsed pane* are not the same case.
- `[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**. - `[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). 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. - `[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.
+18 -4
View File
@@ -323,6 +323,14 @@ as distinct from the fault injection below, which exercises ingest:
bullet) — so a probe run against a fresh config directory will have it on screen for the first bullet) — 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 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`. using, which is why the second run against the same one reads `auto=0`.
- `UDPSCOPE_PROBE_TOUR_SURFACE_MS` — the tour's *surface* switch: it puts the sidebar on **Measure**,
starts the window's tour, and walks it a stop per tick to the signals stop, reading the sidebar's
page and the spotlight/card where the walk arrives. It then puts the sidebar *away* and starts the
tour again, which is the same filter's other case (a collapsed pane drops the stops a page switch
could have revealed). `[toursurface] signals stop=4/5 … side_tab=signals … contains=1` and
`[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0` are the two assertions; its
lines are in [probe-readings.md](probe-readings.md). Run it alone with
`UDPSCOPE_EXIT_AFTER_MS=9000 UDPSCOPE_EXIT_DUMP=1` against a live source.
- `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the - `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 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 `GtkSpinButton` starts at its range's *minimum*, so a `-1e12` here is an unseeded button the
@@ -341,10 +349,16 @@ as distinct from the fault injection below, which exercises ingest:
on, `hints=[0 {}] hint=0x0` with it off. `UDPSCOPE_PROBE_CHOOSER_HELP=1` drives the **Enable on, `hints=[0 {}] hint=0x0` with it off. `UDPSCOPE_PROBE_CHOOSER_HELP=1` drives the **Enable
contextual help** row and reads the file back; `UDPSCOPE_PROBE_CHOOSER_TOUR=1` drives the **Guided contextual help** row and reads the file back; `UDPSCOPE_PROBE_CHOOSER_TOUR=1` drives the **Guided
tour** row and walks the chooser's own tour to its last stop, printing `[sources] chooser tour: …` tour** row and walks the chooser's own tour to its last stop, printing `[sources] chooser tour: …`
and the handoff (`tour_pending=1`); `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=<ms>` schedules the same walk, and the handoff (`tour_pending=1`); `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=<ms>` walks it a stop per tick
which is how the **first-run** auto-start is read (schedule it after 1500 ms, and pair it with instead, printing the page the stack shows at every stop and stepping **Back** off the stop that
`UDPSCOPE_PROBE_PICK_MS` a little later so the application's half follows). Its lines and what each stands on the waveforms page — the reading for the surface a stop changes. It starts the walk
asserts are in [probe-readings.md](probe-readings.md). A legacy file itself when one is not running, so it does not need a first-run config; pair it with
`UDPSCOPE_PROBE_PICK_MS` after the walk so the application's half follows. A working run: seed a
source once (`./build/udpscope --server 127.0.0.1 --port N` against a mock, under the throwaway
`XDG_CONFIG_HOME`), then run bare with
`UDPSCOPE_PROBE_CHOOSER_TOUR_MS=2000 UDPSCOPE_PROBE_PICK_MS=4500 UDPSCOPE_EXIT_AFTER_MS=12000
UDPSCOPE_EXIT_DUMP=1`. Its lines and what each asserts are in
[probe-readings.md](probe-readings.md). A legacy file
(`<host>_<port>.toml`) produces `layout loaded from …/<host>_<port>/default.toml`, an `active` (`<host>_<port>.toml`) produces `layout loaded from …/<host>_<port>/default.toml`, an `active`
holding `default`, and the old file renamed `.imported`. holding `default`, and the old file renamed `.imported`.
- **The loading window covers the gap, and only on the path that asked.** The same - **The loading window covers the gap, and only on the path that asked.** The same
+57 -25
View File
@@ -21,6 +21,7 @@
#include <cstdio> #include <cstdio>
#include <cstdlib> #include <cstdlib>
#include <cstring>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -752,26 +753,30 @@ void chooser_help_sync(Chooser& c) {
/// does -- which is the whole reason the stops cannot live in the window's table. /// does -- which is the whole reason the stops cannot live in the window's table.
struct ChooserStop { struct ChooserStop {
GtkWidget* Chooser::* target; GtkWidget* Chooser::* target;
/// The page of the chooser's own stack the stop stands on. The tabs stop is the one about the two
/// kinds of input, so it shows the *waveforms* page while it is up; every other stop stands on the
/// sources page, and naming it is what brings the page back when the walk steps off the tabs stop.
const char* page;
const char* title; const char* title;
const char* body; const char* body;
}; };
constexpr ChooserStop kChooserStops[] = { constexpr ChooserStop kChooserStops[] = {
{&Chooser::list, "Choosing a source", {&Chooser::list, "sources", "Choosing a source",
"Every source you have saved, newest first. Clicking one opens it -- that is the choice this " "Every source you have saved, newest first. Clicking one opens it -- that is the choice this "
"dialog exists to make -- and the arrows on a row edit or forget its configuration."}, "dialog exists to make -- and the arrows on a row edit or forget its configuration."},
{&Chooser::switcher, "Two kinds of input", {&Chooser::switcher, "waveforms", "Two kinds of input",
"A *source* is a stream to receive; a *waveform* is a recording an earlier run exported. They " "A *source* is a stream to receive; a *waveform* is a recording an earlier run exported. They "
"are the two answers to \u201cwhat should be on the screen\u201d, and these are the tabs " "are the two answers to \u201cwhat should be on the screen\u201d, and these are the tabs "
"between them."}, "between them."},
{&Chooser::new_host, "A source that is not listed", {&Chooser::new_host, "sources", "A source that is not listed",
"Opens the form for an address that has no configuration yet: host, port, the settings a source " "Opens the form for an address that has no configuration yet: host, port, the settings a source "
"can only be given before it is opened, and the name its session will be saved under."}, "can only be given before it is opened, and the name its session will be saved under."},
{&Chooser::help_button, "The manual, and this tour", {&Chooser::help_button, "sources", "The manual, and this tour",
"Documentation opens the manual in the desktop help viewer. This tour walks this window and " "Documentation opens the manual in the desktop help viewer. This tour walks this window and "
"then the application's own window. Contextual help is the switch below it: on, every surface " "then the application's own window. Contextual help is the switch below it: on, every surface "
"carries a small \u201c(i)\u201d saying what it is for."}, "carries a small \u201c(i)\u201d saying what it is for."},
{&Chooser::prefs_button, "Preferences", {&Chooser::prefs_button, "sources", "Preferences",
"The event cache, relative time and the interface theme. This is the last moment relative time " "The event cache, relative time and the interface theme. This is the last moment relative time "
"can still change this run -- the buffers are planned as soon as a source is picked -- which is " "can still change this run -- the buffers are planned as soon as a source is picked -- which is "
"why the button is here and not only in the window's menu."}, "why the button is here and not only in the window's menu."},
@@ -806,16 +811,16 @@ void chooser_tour_mark_seen() {
void chooser_tour_begin(Chooser& c) { void chooser_tour_begin(Chooser& c) {
if (c.tour.card == nullptr) return; if (c.tour.card == nullptr) return;
chooser_tour_mark_seen(); chooser_tour_mark_seen();
// The stops whose target is on screen, in table order, the same filter the window's tour uses: a // The chooser's own stack, so a stop can name the page it stands on (the window does the same with
// surface that is not there is not walked over. // its sidebar in `start_tour()`).
c.tour.page_stack = c.page_stack;
// The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen.
std::vector<ui_impl::TourStop> stops; std::vector<ui_impl::TourStop> stops;
stops.reserve(kChooserStopCount);
for (int i = 0; i < kChooserStopCount; ++i) { for (int i = 0; i < kChooserStopCount; ++i) {
GtkWidget* target = c.*(kChooserStops[i].target); GtkWidget* target = c.*(kChooserStops[i].target);
if (target != nullptr && gtk_widget_is_visible(target)) { stops.push_back({target, kChooserStops[i].page, kChooserStops[i].title, kChooserStops[i].body});
stops.push_back({target, kChooserStops[i].title, kChooserStops[i].body});
} }
}
if (stops.empty()) return;
ui_impl::tour_begin(c.tour, std::move(stops), chooser_tour_done, &c); ui_impl::tour_begin(c.tour, std::move(stops), chooser_tour_done, &c);
} }
@@ -851,26 +856,53 @@ gboolean chooser_tour_autostart(gpointer user) {
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
/// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`: the chooser's tour, read and walked the way the window's is -- /// Whether the probe has done its one Back step. One chooser runs per process, so a file-static is
/// where it stands, then every stop, then the handoff. It reports the walk by *stepping* it (the /// enough and simpler than a field on `Chooser` that exists only for the probe.
/// button the user presses), so what it reads is what a first run sees, not the state the probe set. bool g_chooser_tour_backed = false;
/// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`: the chooser's tour, read and walked a stop per tick -- the
/// cadence the window's own tour probe walks at, and for its reason: a page a stop selects is laid
/// out on the frame *after* the switch, so a reading taken in the switch's own tick would be of the
/// page the walk has just left. It starts the walk if one is not already running, so it does not
/// depend on the throwaway config being a first run; it reports the page the stack shows at every
/// stop; it steps **Back** off the stop that stands on the waveforms page and reads the page again
/// -- the other direction; then it finishes the walk. `page=waveforms` on that stop and `page=sources`
/// after the Back step is the pair the surface switch is read by.
gboolean chooser_tour_probe(gpointer user) { gboolean chooser_tour_probe(gpointer user) {
Chooser& c = *static_cast<Chooser*>(user); Chooser& c = *static_cast<Chooser*>(user);
c.tour_probe_source = 0; c.tour_probe_source = 0;
std::fprintf(stderr, "[sources] chooser tour: visible=%d steps=%d step=%d\n", const auto page = [&c]() {
c.tour.running ? 1 : 0, static_cast<int>(c.tour.plan.size()), c.tour.index); const char* name = adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack));
while (c.tour.running) { return name != nullptr ? name : "?";
const int before = c.tour.index; };
ui_impl::tour_advance(c.tour, 1); if (!c.tour.running) chooser_tour_begin(c);
if (c.tour.running) { if (!c.tour.running) {
std::fprintf(stderr, "[sources] chooser tour: next -> step=%d/%d\n", c.tour.index, std::fprintf(stderr, "[sources] chooser tour: nothing to walk (page=%s)\n", page());
static_cast<int>(c.tour.plan.size())); std::fflush(stderr);
return G_SOURCE_REMOVE;
}
const std::size_t idx = static_cast<std::size_t>(c.tour.index);
const int n = static_cast<int>(c.tour.plan.size());
const ui_impl::TourStop& stop = c.tour.plan[idx];
std::fprintf(stderr, "[sources] chooser tour: step=%d/%d page=%s stop_page=%s target_mapped=%d\n",
c.tour.index + 1, n, page(), stop.page != nullptr ? stop.page : "-",
stop.target != nullptr && gtk_widget_get_mapped(stop.target) ? 1 : 0);
if (stop.page != nullptr && std::strcmp(stop.page, "waveforms") == 0 && !g_chooser_tour_backed) {
// Off it, Back: the previous stop stands on the sources page, and the page has to come back with
// it -- the other direction a surface switch has to work in.
g_chooser_tour_backed = true;
ui_impl::tour_advance(c.tour, -1);
std::fprintf(stderr, "[sources] chooser tour: back off the waveforms stop: step=%d/%d page=%s\n",
c.tour.index + 1, n, page());
} else { } else {
std::fprintf(stderr, "[sources] chooser tour: Next past stop %d finished the walk\n", before + 1); ui_impl::tour_advance(c.tour, 1);
} }
if (c.tour.running) {
c.tour_probe_source = g_timeout_add(250, chooser_tour_probe, &c);
} else {
std::fprintf(stderr, "[sources] chooser tour: after finish visible=0 page=%s tour_pending=%d\n",
page(), c.cfg->tour_pending ? 1 : 0);
} }
std::fprintf(stderr, "[sources] chooser tour: after finish visible=%d tour_pending=%d\n",
c.tour.running ? 1 : 0, c.cfg->tour_pending ? 1 : 0);
std::fflush(stderr); std::fflush(stderr);
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
+3
View File
@@ -5808,6 +5808,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELPUI_MS")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_HELPUI_MS")) {
helpui_probe_next(app, 0, static_cast<guint>(std::atoi(env))); helpui_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
} }
if (const char* env = g_getenv("UDPSCOPE_PROBE_TOUR_SURFACE_MS")) {
tour_surface_probe_next(app, static_cast<guint>(std::atoi(env)));
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_CONFIGS_MS")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_CONFIGS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), configs_probe, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), configs_probe, &app);
} }
+21 -4
View File
@@ -67,10 +67,15 @@ enum class EventGroupKind {
// is only the *stops*: a stop's target is a widget the owner resolves, so each window keeps its own // is only the *stops*: a stop's target is a widget the owner resolves, so each window keeps its own
// table (the chooser runs before `App` exists and could not name one of `App`'s fields). // table (the chooser runs before `App` exists and could not name one of `App`'s fields).
/// One stop of a guided tour: the widget to spotlight, and the two paragraphs of text beside it. /// One stop of a guided tour: the widget to spotlight, the page it stands on, and the two paragraphs
/// The target is resolved when the tour starts, so the table may name widgets that do not exist yet. /// of text beside it. The target is resolved when the tour starts, so the table may name widgets that
/// do not exist yet.
struct TourStop { struct TourStop {
GtkWidget* target; GtkWidget* target;
/// The named page of the view's `page_stack` the target lives on, or nullptr when the stop needs no
/// page. The tour selects it as the stop is entered, so a target on a page the user has left is
/// made visible instead of being walked over or spotlighted where it is not.
const char* page;
const char* title; const char* title;
const char* body; const char* body;
}; };
@@ -93,6 +98,10 @@ struct TourView {
bool running = false; bool running = false;
int index = 0; int index = 0;
std::vector<TourStop> plan; std::vector<TourStop> plan;
/// The stack whose pages the stops' `page` names select. A window has exactly one such surface (the
/// sidebar; the chooser's two pages), so it is the view's and not each stop's. Set where the walk
/// begins, when the widgets exist; null leaves every stop on the page it finds.
GtkWidget* page_stack = nullptr;
/// Runs when the walk passes its last stop (Next/Finish, never Skip): the chooser's hands the tour /// Runs when the walk passes its last stop (Next/Finish, never Skip): the chooser's hands the tour
/// on to the application through `Config::tour_pending`. /// on to the application through `Config::tour_pending`.
void (*on_done)(void* user) = nullptr; void (*on_done)(void* user) = nullptr;
@@ -312,6 +321,8 @@ struct App {
/// left nothing behind. /// left nothing behind.
int dbg_helpui_step = 0; int dbg_helpui_step = 0;
int dbg_helpui_controllers = -1; int dbg_helpui_controllers = -1;
/// `UDPSCOPE_PROBE_TOUR_SURFACE_MS`: which step of that probe runs next.
int dbg_tour_surface_step = 0;
/// Where the panel controls ended up, decided once at build time: the dump runs after the window /// 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. /// is destroyed and cannot ask GTK anything about a widget.
std::string dbg_panel_controls; std::string dbg_panel_controls;
@@ -1745,6 +1756,10 @@ gboolean keys_probe(gpointer user);
gboolean help_probe(gpointer user); gboolean help_probe(gpointer user);
void helpui_probe_next(App& a, int step, guint delay_ms); void helpui_probe_next(App& a, int step, guint delay_ms);
gboolean helpui_probe_step(gpointer user); gboolean helpui_probe_step(gpointer user);
/// `UDPSCOPE_PROBE_TOUR_SURFACE_MS`: a stop that stands on another page of a stack, entered from the
/// page it is not on -- the sidebar put on Measure, the walk to the signals stop, the reading of the
/// page and of the spotlight, and then the same walk with the sidebar *collapsed*.
void tour_surface_probe_next(App& a, guint delay_ms);
// --- the help affordance layer (ui_context_help.cpp, ui_tour.cpp) ---------- // --- the help affordance layer (ui_context_help.cpp, ui_tour.cpp) ----------
// //
@@ -1784,8 +1799,10 @@ void context_help_size(App& app, int* width, int* height);
/// Builds a tour's overlay children (the four dim rectangles, the spotlight outline and the card) /// Builds a tour's overlay children (the four dim rectangles, the spotlight outline and the card)
/// inside `overlay`. Called once, as the overlay is inserted, by either window that draws a tour. /// inside `overlay`. Called once, as the overlay is inserted, by either window that draws a tour.
void tour_build(TourView& view, GtkWidget* overlay); void tour_build(TourView& view, GtkWidget* overlay);
/// Starts at the first stop. `stops` is the walk, already filtered to what is on screen; `on_done` /// Starts at the first stop. `stops` is the whole table, in walk order: the stops whose surface is
/// (may be null) runs when the walk passes its last stop, and is how the chooser hands off. /// not on screen are filtered out here, by the one rule both windows use (a wrong page is kept and
/// selected, a collapsed pane is dropped). `on_done` (may be null) runs when the walk passes its last
/// stop, and is how the chooser hands off.
void tour_begin(TourView& view, std::vector<TourStop> stops, void (*on_done)(void* user), void tour_begin(TourView& view, std::vector<TourStop> stops, void (*on_done)(void* user),
void* done_user); void* done_user);
/// Moves `delta` stops, finishing the walk (and calling `on_done`) on the way past the last one. /// Moves `delta` stops, finishing the walk (and calling `on_done`) on the way past the last one.
+120 -9
View File
@@ -4557,16 +4557,19 @@ GtkWidget* helpui_menu_view(App* a) {
} }
/// One line: the spotlight, the widget it spotlights, and the card, all in **window coordinates** -- /// 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 /// the frame a screenshot is cropped in -- with the window's own record of the sidebar page beside
/// target's own rectangle, grown three pixels) and `clear` is the card missing the target. /// them, so a stop that had to change the page to find its target is readable as such. `contains` is
void helpui_report_geometry(App& a, const char* what) { /// the spotlight covering its target (it is the target's own rectangle, grown three pixels) and
/// `clear` is the card missing the target. `probe` is the tag it is printed under, so a window with
/// more than one tour probe can tell its lines apart.
void tour_geometry_line(App& a, const char* probe, const char* what) {
graphene_rect_t spot = GRAPHENE_RECT_INIT(0, 0, 0, 0); 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 target = GRAPHENE_RECT_INIT(0, 0, 0, 0);
graphene_rect_t card = GRAPHENE_RECT_INIT(0, 0, 0, 0); graphene_rect_t card = GRAPHENE_RECT_INIT(0, 0, 0, 0);
GtkWidget* t = tour_target(a); GtkWidget* t = tour_target(a);
const bool have_spot = const bool have_spot =
a.tour.spot != nullptr && gtk_widget_compute_bounds(a.tour.spot, a.window, &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) && const bool have_target = t != nullptr && gtk_widget_get_mapped(t) &&
gtk_widget_compute_bounds(t, a.window, &target); gtk_widget_compute_bounds(t, a.window, &target);
const bool have_card = const bool have_card =
a.tour.card != nullptr && gtk_widget_compute_bounds(a.tour.card, a.window, &card); a.tour.card != nullptr && gtk_widget_compute_bounds(a.tour.card, a.window, &card);
@@ -4580,11 +4583,11 @@ void helpui_report_geometry(App& a, const char* what) {
p.origin.y < q.origin.y + q.size.height && q.origin.y < p.origin.y + p.size.height; p.origin.y < q.origin.y + q.size.height && q.origin.y < p.origin.y + p.size.height;
}; };
std::fprintf(stderr, std::fprintf(stderr,
"[helpui] %s=%d/%d visible=%d spot=[%.0f,%.0f %.0fx%.0f] " "[%s] %s=%d/%d visible=%d side_tab=%s spot=[%.0f,%.0f %.0fx%.0f] "
"target=[%.0f,%.0f %.0fx%.0f] contains=%d card=[%.0f,%.0f %.0fx%.0f] clear=%d\n", "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, probe, 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, a.side_tab.c_str(), spot.origin.x, spot.origin.y, spot.size.width, spot.size.height,
target.origin.y, target.size.width, target.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, have_spot && have_target && contains(spot, target) ? 1 : 0, card.origin.x,
card.origin.y, card.size.width, card.size.height, card.origin.y, card.size.width, card.size.height,
have_card && have_target && !overlaps(card, target) ? 1 : 0); have_card && have_target && !overlaps(card, target) ? 1 : 0);
@@ -4715,7 +4718,7 @@ gboolean helpui_probe_step(gpointer user) {
// spotlight must *contain* the target, and the card must not overlap it when the window has room // 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 // 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. // the step it describes: `update_tour()` places this frame from the allocation the last one left.
helpui_report_geometry(*a, "step"); tour_geometry_line(*a, "helpui", "step");
if (step == 4) { if (step == 4) {
// Next, which is what a user presses to walk the tour. // Next, which is what a user presses to walk the tour.
tour_step(*a, 1); tour_step(*a, 1);
@@ -4774,5 +4777,113 @@ gboolean helpui_probe_step(gpointer user) {
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
// --- the tour's surface switch: a stop that stands on another stack page -------
/// Whether the walk's plan holds `target`. `steps=` alone cannot say a stop was *filtered out* rather
/// than merely reordered, so the two cases the filter tells apart are read against the plan itself.
bool tour_plan_holds(const App& a, GtkWidget* target) {
for (const TourStop& stop : a.tour.plan) {
if (stop.target == target) return true;
}
return false;
}
gboolean tour_surface_probe_step(gpointer user);
void tour_surface_probe_next(App& a, guint delay_ms) {
g_timeout_add(delay_ms, tour_surface_probe_step, &a);
}
/// `UDPSCOPE_PROBE_TOUR_SURFACE_MS`: the stop that stands on a page it is not on. It puts the sidebar
/// on **Measure**, starts the window's tour, and walks it a stop per tick -- a tick apart on purpose,
/// because a stack's new page is allocated on the frame *after* the switch, so a reading taken in the
/// switch's own tick would be of the page the walk has just left (and `update_tour()` places the hole
/// from the frame that *has* been laid out). Every step prints the sidebar's page and whether the
/// stop's target is mapped, and the stop whose target is the signal list prints the same geometry
/// `[helpui]` does: **`side_tab=signals` with `target=signal_list` and `contains=1`** is the reading
/// that the stop was not merely kept but *made* visible. It then puts the sidebar away --
/// `sidebar_wanted` is the wish the tick re-asserts -- and starts the tour again: a *collapsed* pane
/// leaves the stack unmapped, so the stops that need it are dropped and `steps=` falls. That pair is
/// the filter's two cases, read rather than assumed.
gboolean tour_surface_probe_step(gpointer user) {
App* a = static_cast<App*>(user);
if (a->window == nullptr) return G_SOURCE_REMOVE;
const auto mapped = [](GtkWidget* w) { return w != nullptr && gtk_widget_get_mapped(w) ? 1 : 0; };
switch (a->dbg_tour_surface_step) {
case 0: {
// Where the run starts: a fresh config's sidebar is on Signals, so this is a *first* run's
// state. The auto-start's walk (if one began) would only confuse the reading; it goes first.
std::fprintf(stderr, "[toursurface] before: side_tab=%s signal_list_mapped=%d running=%d\n",
a->side_tab.c_str(), mapped(a->signal_list), tour_active(*a) ? 1 : 0);
tour_dismiss(*a);
select_side_tab(*a, "measure");
a->dbg_tour_surface_step = 1;
tour_surface_probe_next(*a, 500);
return G_SOURCE_REMOVE;
}
case 1: {
// A frame after the switch, so the Measure page has been laid out and the list really is off
// screen. The tour is started *here*, from this state.
std::fprintf(stderr,
"[toursurface] on measure: side_tab=%s signal_list_mapped=%d side_stack_mapped=%d\n",
a->side_tab.c_str(), mapped(a->signal_list), mapped(a->side_stack));
start_tour(*a);
std::fprintf(stderr, "[toursurface] tour: visible=%d steps=%d step=%d signals_stop=%d\n",
tour_active(*a) ? 1 : 0, tour_step_count(*a), tour_step_index(*a),
tour_plan_holds(*a, a->signal_list) ? 1 : 0);
a->dbg_tour_surface_step = 2;
tour_surface_probe_next(*a, 400);
return G_SOURCE_REMOVE;
}
case 2: {
if (!tour_active(*a)) {
std::fprintf(stderr, "[toursurface] walk ended before the signals stop\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
GtkWidget* target = tour_target(*a);
const bool is_list = target != nullptr && target == a->signal_list;
std::fprintf(stderr, "[toursurface] step=%d/%d side_tab=%s target_mapped=%d%s\n",
tour_step_index(*a) + 1, tour_step_count(*a), a->side_tab.c_str(), mapped(target),
is_list ? " target=signal_list" : "");
if (is_list) {
// The frame after the switch: the page has been laid out and `update_tour()` has placed the
// hole from it, so this is the reading that the spotlight is on the list.
tour_geometry_line(*a, "toursurface", "signals stop");
tour_dismiss(*a);
a->dbg_tour_surface_step = 3;
tour_surface_probe_next(*a, 400);
return G_SOURCE_REMOVE;
}
tour_step(*a, 1);
tour_surface_probe_next(*a, 400);
return G_SOURCE_REMOVE;
}
case 3: {
// The other half of the filter. `sidebar_wanted` is the user's wish that the tick re-asserts
// (`update_side_visibility`), so setting it here is how a probe puts the sidebar away for good;
// the delay leaves the split view time to animate the column shut and unmap it.
a->sidebar_wanted = false;
a->dbg_tour_surface_step = 4;
tour_surface_probe_next(*a, 700);
return G_SOURCE_REMOVE;
}
default: {
start_tour(*a);
std::fprintf(stderr,
"[toursurface] collapsed: side_stack_mapped=%d steps=%d signals_stop=%d "
"side_stop=%d\n",
mapped(a->side_stack), tour_step_count(*a),
tour_plan_holds(*a, a->signal_list) ? 1 : 0,
tour_plan_holds(*a, a->side_box) ? 1 : 0);
tour_dismiss(*a);
a->sidebar_wanted = true;
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
}
return G_SOURCE_REMOVE;
}
} // namespace ui_impl } // namespace ui_impl
} // namespace udpscope } // namespace udpscope
+63 -15
View File
@@ -76,10 +76,42 @@ const TourStop* current_stop(const TourView& view) {
return &view.plan[static_cast<std::size_t>(view.index)]; return &view.plan[static_cast<std::size_t>(view.index)];
} }
/// Selects a stop's page on the view's stack, before anything is measured against the target. Both
/// windows' stacks are `AdwViewStack`s and their page names are the ones the same tables name their
/// `.ui` pages with, so the machinery can do this itself; the `get_child_by_name()` guard keeps a
/// stop that names a page a later `.ui` dropped from blanking the stack. The stack's own
/// `notify::visible-child-name` is what tells the window which page it is now on.
void select_view_page(TourView& view, const char* page) {
if (view.page_stack == nullptr || page == nullptr) return;
if (adw_view_stack_get_child_by_name(ADW_VIEW_STACK(view.page_stack), page) == nullptr) return;
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(view.page_stack), page);
}
/// Whether a stop belongs in the walk. `get_mapped()`, not `is_visible()`: a stack hides its other
/// pages, and the split view its sidebar, with `gtk_widget_set_child_visible()`, which leaves the
/// widget's own visible flag -- the one `is_visible()` walks up the parents -- **true**. So the only
/// honest "is this on screen" test is whether it is mapped, and it is the one that keeps the filter's
/// two cases apart:
/// * a target not mapped because it is on *another page* is walked over when the stop names that
/// page and the stack itself is on screen -- selecting the page is what puts the target there;
/// * a target not mapped because its *pane is collapsed* leaves the stack unmapped too, so no page
/// can reveal it and the stop is still dropped.
bool stop_is_walkable(const TourView& view, const TourStop& stop) {
if (stop.target == nullptr) return false;
if (gtk_widget_get_mapped(stop.target)) return true;
return stop.page != nullptr && view.page_stack != nullptr &&
gtk_widget_get_mapped(view.page_stack);
}
/// Restates the card's text for the current stop. Called on a change of step, not per frame. /// Restates the card's text for the current stop. Called on a change of step, not per frame.
void show_step(TourView& view) { void show_step(TourView& view) {
const TourStop* stop = current_stop(view); const TourStop* stop = current_stop(view);
if (stop == nullptr || view.card == nullptr) return; if (stop == nullptr || view.card == nullptr) return;
// The surface first, and nothing is measured here: the stop may live on a page the user has left,
// and a stack's new page is allocated on the frame *after* the switch, so a one-shot measure in
// this tick would read the page the walk has just left. `tour_update()` runs every frame and fills
// the hole in on the next one.
select_view_page(view, stop->page);
// The hole belongs to the step: reusing the last one for a frame would put the spotlight on the // 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. // surface the tour has just left, and the next frame fills this one in.
view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0; view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0;
@@ -170,8 +202,16 @@ void tour_build(TourView& view, GtkWidget* overlay) {
void tour_begin(TourView& view, std::vector<TourStop> stops, void (*on_done)(void* user), void tour_begin(TourView& view, std::vector<TourStop> stops, void (*on_done)(void* user),
void* done_user) { void* done_user) {
if (view.overlay == nullptr || view.card == nullptr) return; if (view.overlay == nullptr || view.card == nullptr) return;
view.plan = std::move(stops); // The walk is the stops whose surface is on screen *or* can be selected into view, filtered here
if (view.plan.empty()) return; // rather than by each caller so the two windows cannot drift into two rules -- and because the
// rule needs the view's own `page_stack`, which the caller's table does not carry. See
// `stop_is_walkable()` for what separates a wrong page from a collapsed pane.
std::vector<TourStop> plan;
for (const TourStop& stop : stops) {
if (stop_is_walkable(view, stop)) plan.push_back(stop);
}
if (plan.empty()) return;
view.plan = std::move(plan);
view.on_done = on_done; view.on_done = on_done;
view.done_user = done_user; view.done_user = done_user;
view.index = 0; view.index = 0;
@@ -228,7 +268,10 @@ void tour_update(TourView& view) {
// one-shot measurement would leave the hole in the corner. // one-shot measurement would leave the hole in the corner.
GtkWidget* target = tour_target(view); GtkWidget* target = tour_target(view);
graphene_rect_t bounds = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f); 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); // Mapped, not `is_visible()`: a widget on another page of a stack, or in a collapsed pane, keeps
// its own visible flag -- a stack hides its pages with `set_child_visible()` -- and a widget that
// is not on screen is not one to leave the spotlight standing on.
const bool target_present = target != nullptr && gtk_widget_get_mapped(target);
if (target_present && gtk_widget_compute_bounds(target, view.overlay, &bounds)) { if (target_present && gtk_widget_compute_bounds(target, view.overlay, &bounds)) {
// The hole: the target, grown by three pixels so the outline sits *around* the widget rather than // 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 // on it, and clamped to the overlay -- a target partly off screen is an ordinary case (a sidebar
@@ -331,29 +374,33 @@ namespace {
/// point of re-measuring every frame is that nothing here knows where the widget is. /// point of re-measuring every frame is that nothing here knows where the widget is.
struct TourStep { struct TourStep {
const char* id; const char* id;
/// The sidebar page the target lives on, or nullptr when the stop is not on either page. The
/// trigger and the channel list are both on the Signals page, so a stop about either has to put
/// the sidebar there before it can spotlight one.
const char* page;
const char* title; const char* title;
const char* body; const char* body;
GtkWidget* App::* target; GtkWidget* App::* target;
}; };
constexpr TourStep kTourSteps[] = { constexpr TourStep kTourSteps[] = {
{"plot", "The plot", {"plot", nullptr, "The plot",
"Every signal you have switched on is drawn here, all rows sharing one time axis. The wheel " "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.", "zooms, a drag pans, and a right-click on a row opens that plot's own menu.",
&App::area_overlay}, &App::area_overlay},
{"side", "The side panel", {"side", nullptr, "The side panel",
"The working surface beside the plot. Measure holds the cursors and the measurements; Signals " "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.", "holds the trigger and the list of signals. The buttons in the bar above show and hide it.",
&App::side_box}, &App::side_box},
{"trigger", "The trigger", {"trigger", "signals", "The trigger",
"Describe an event -- a signal, a condition, a level and the window around it -- and arm it: " "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.", "the view then freezes on the first event that matches, however rare it is.",
&App::trigger_body}, &App::trigger_body},
{"signals", "The signals", {"signals", "signals", "The signals",
"Every signal the source declares, each with the switch that acquires it. A signal has to be " "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.", "switched on before it can be plotted, measured or searched.",
&App::signal_list}, &App::signal_list},
{"status", "The status line", {"status", nullptr, "The status line",
"The stream's own report, once a second: the display mode, the rate arriving, the window on " "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.", "screen and the buffer holding it. The information button opens the counters behind it.",
&App::status_row}, &App::status_row},
@@ -375,17 +422,18 @@ void start_tour(App& app) {
app.settings.tour_seen = true; app.settings.tour_seen = true;
(void)save_settings(app.settings); (void)save_settings(app.settings);
} }
// The stops whose target is on screen, in table order. A surface that is not there is not walked // The sidebar's own stack, so a stop can name the page it stands on. Set here and not in
// over -- the point of asking `is_visible()` rather than `get_visible()` is that a widget under a // `build_tour()`: the overlay is inserted above the window's content *before* `App::side_stack` is
// hidden parent still reports its own flag as true. // assigned, so the member does not exist yet at build time -- and the walk cannot start before the
// window is up anyway.
app.tour.page_stack = app.side_stack;
// The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen.
std::vector<TourStop> stops; std::vector<TourStop> stops;
stops.reserve(kTourStepCount);
for (int i = 0; i < kTourStepCount; ++i) { for (int i = 0; i < kTourStepCount; ++i) {
GtkWidget* target = app.*(kTourSteps[i].target); GtkWidget* target = app.*(kTourSteps[i].target);
if (target != nullptr && gtk_widget_is_visible(target)) { stops.push_back({target, kTourSteps[i].page, kTourSteps[i].title, kTourSteps[i].body});
stops.push_back({target, kTourSteps[i].title, kTourSteps[i].body});
} }
}
if (stops.empty()) return;
tour_begin(app.tour, std::move(stops), nullptr, nullptr); tour_begin(app.tour, std::move(stops), nullptr, nullptr);
} }