diff --git a/README.md b/README.md index 4a8813a..98281d7 100644 --- a/README.md +++ b/README.md @@ -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 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* -cannot start anything -- the tour is drawn on the app's window, which does not exist yet -- so it sets -**`Config::tour_pending`**, the value `main()` owns and hands to both the chooser and the front end -(`open_waveform` crosses the same way). Half a second and a half after the window is up, the same -delay the first-run auto-start uses, the pending request starts the tour; it sets `tour_requested` -rather than `tour_auto_started`, so the readings say which of the two it was. The chooser's rows show -**no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds them, and this dialog -binds none. +starts **the chooser's own walk** -- the surfaces a first run meets first are this dialog's own -- +whose last step sets **`Config::tour_pending`**, the value `main()` owns and hands to both the chooser +and the front end (`open_waveform` crosses the same way). Half a second and a half after the window is +up, the same delay the first-run auto-start uses, the pending request starts the window's tour; it sets +`tour_requested` rather than `tour_auto_started`, so the readings say which of the two it was. The +chooser's rows show **no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds +them, and this dialog binds none. **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). +and either side of the target -- that swallow the clicks that land on them. A stop declares the +**page it stands on** (the trigger and the signals steps live on the sidebar's *Signals* page, named +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 widget's allocation is 0 on the frame it is first shown and a window resize moves it under a diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 2c3451d..07d80ce 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -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 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. +- **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 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 @@ -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. - **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 + 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 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. diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index e536f6d..0ffab93 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -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 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 - `[sources] chooser tour: next -> step=N/5` per press, then `[sources] chooser tour: finished 5 - stops -- the window continues it (tour_pending=1)`. **`tour_pending=1` and `visible=0` is the - handoff assertion** — the walk is over here and the request rides on the one `Config` value - `main()` hands to both windows. - - `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=` schedules the same walk on a timer, for reading a tour - **the run itself started** (the first-run auto-start) rather than one the probe started; it must - be later than 1500 ms. Paired with `UDPSCOPE_PROBE_PICK_MS` a few hundred ms later, the run then - 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. + started in the chooser`, then `[sources] chooser tour: visible=1 steps=5 step=0`, then + `[sources] chooser tour: finished 5 stops -- the window continues it (tour_pending=1)` and + `[sources] chooser tour: after finish visible=0 tour_pending=1`. **`tour_pending=1` and + `visible=0` is the handoff assertion** — the walk is over here and the request rides on the one + `Config` value `main()` hands to both windows. The walk is driven in one tick (it is the handoff + being asserted, not the geometry), so the pages it selects are not readable here; the timed + `_MS` form below walks a stop per tick and reads them. + - `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=` walks the tour a stop per **tick** (250 ms), the cadence + the window's own probe uses and for its reason: a page a stop selects is allocated on the frame + after the switch. It starts the walk itself when one is not already running, so it does not + 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= stop_page= 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/_.toml`) — run once 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 @@ -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 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`. + `Ctrl+T` starts it. `steps=5` is the plan: the table's five steps whose surface is on screen or + selectable into it (`gtk_widget_get_mapped()` -- a wrong *page* is kept, because selecting its + page reveals it, while a *collapsed pane* is still dropped; see the surface bullets in + [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). + - `[helpui] step=1/5 visible=1 side_tab=signals spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` + — **in window coordinates**, the frame a screenshot is cropped in, with the sidebar's page beside + it (a stop the walk had to change the page for is readable as such; see `[toursurface]` below). + **`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 @@ -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 configuration mode against 72 locked — the body is replaced by the summary), which is the check 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= 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**. 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 `: : ->` — 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] '' 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 `` 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. diff --git a/docs/claude/verification.md b/docs/claude/verification.md index a60021d..bcd8e4e 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -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 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_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 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 @@ -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 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: …` - and the handoff (`tour_pending=1`); `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=` schedules the same walk, - which is how the **first-run** auto-start is read (schedule it after 1500 ms, and pair it with - `UDPSCOPE_PROBE_PICK_MS` a little later so the application's half follows). Its lines and what each - asserts are in [probe-readings.md](probe-readings.md). A legacy file + and the handoff (`tour_pending=1`); `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=` walks it a stop per tick + instead, printing the page the stack shows at every stop and stepping **Back** off the stop that + stands on the waveforms page — the reading for the surface a stop changes. It starts the walk + 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 (`_.toml`) produces `layout loaded from …/_/default.toml`, an `active` holding `default`, and the old file renamed `.imported`. - **The loading window covers the gap, and only on the path that asked.** The same diff --git a/src/source_dialog.cpp b/src/source_dialog.cpp index 9408e38..aa44899 100644 --- a/src/source_dialog.cpp +++ b/src/source_dialog.cpp @@ -21,6 +21,7 @@ #include #include +#include #include #include @@ -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. struct ChooserStop { 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* body; }; 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 " "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 " "are the two answers to \u201cwhat should be on the screen\u201d, and these are the tabs " "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 " "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 " "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."}, - {&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 " "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."}, @@ -806,16 +811,16 @@ void chooser_tour_mark_seen() { void chooser_tour_begin(Chooser& c) { if (c.tour.card == nullptr) return; chooser_tour_mark_seen(); - // The stops whose target is on screen, in table order, the same filter the window's tour uses: a - // surface that is not there is not walked over. + // The chooser's own stack, so a stop can name the page it stands on (the window does the same with + // 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 stops; + stops.reserve(kChooserStopCount); for (int i = 0; i < kChooserStopCount; ++i) { GtkWidget* target = c.*(kChooserStops[i].target); - if (target != nullptr && gtk_widget_is_visible(target)) { - stops.push_back({target, kChooserStops[i].title, kChooserStops[i].body}); - } + stops.push_back({target, kChooserStops[i].page, kChooserStops[i].title, kChooserStops[i].body}); } - if (stops.empty()) return; 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; } -/// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`: the chooser's tour, read and walked the way the window's is -- -/// where it stands, then every stop, then the handoff. It reports the walk by *stepping* it (the -/// button the user presses), so what it reads is what a first run sees, not the state the probe set. +/// Whether the probe has done its one Back step. One chooser runs per process, so a file-static is +/// enough and simpler than a field on `Chooser` that exists only for the probe. +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) { Chooser& c = *static_cast(user); c.tour_probe_source = 0; - std::fprintf(stderr, "[sources] chooser tour: visible=%d steps=%d step=%d\n", - c.tour.running ? 1 : 0, static_cast(c.tour.plan.size()), c.tour.index); - while (c.tour.running) { - const int before = c.tour.index; + const auto page = [&c]() { + const char* name = adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)); + return name != nullptr ? name : "?"; + }; + if (!c.tour.running) chooser_tour_begin(c); + if (!c.tour.running) { + std::fprintf(stderr, "[sources] chooser tour: nothing to walk (page=%s)\n", page()); + std::fflush(stderr); + return G_SOURCE_REMOVE; + } + const std::size_t idx = static_cast(c.tour.index); + const int n = static_cast(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 { ui_impl::tour_advance(c.tour, 1); - if (c.tour.running) { - std::fprintf(stderr, "[sources] chooser tour: next -> step=%d/%d\n", c.tour.index, - static_cast(c.tour.plan.size())); - } else { - std::fprintf(stderr, "[sources] chooser tour: Next past stop %d finished the walk\n", before + 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); return G_SOURCE_REMOVE; } diff --git a/src/ui.cpp b/src/ui.cpp index efe67e4..7449a81 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -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")) { helpui_probe_next(app, 0, static_cast(std::atoi(env))); } + if (const char* env = g_getenv("UDPSCOPE_PROBE_TOUR_SURFACE_MS")) { + tour_surface_probe_next(app, static_cast(std::atoi(env))); + } if (const char* env = g_getenv("UDPSCOPE_PROBE_CONFIGS_MS")) { g_timeout_add(static_cast(std::atoi(env)), configs_probe, &app); } diff --git a/src/ui_app.hpp b/src/ui_app.hpp index f9d38cb..9cf150a 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -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 // 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. -/// The target is resolved when the tour starts, so the table may name widgets that do not exist yet. +/// One stop of a guided tour: the widget to spotlight, the page it stands on, and the two paragraphs +/// 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 { 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* body; }; @@ -93,6 +98,10 @@ struct TourView { bool running = false; int index = 0; std::vector 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 /// on to the application through `Config::tour_pending`. void (*on_done)(void* user) = nullptr; @@ -312,6 +321,8 @@ struct App { /// left nothing behind. int dbg_helpui_step = 0; 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 /// is destroyed and cannot ask GTK anything about a widget. std::string dbg_panel_controls; @@ -1745,6 +1756,10 @@ 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); +/// `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) ---------- // @@ -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) /// inside `overlay`. Called once, as the overlay is inserted, by either window that draws a tour. 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` -/// (may be null) runs when the walk passes its last stop, and is how the chooser hands off. +/// Starts at the first stop. `stops` is the whole table, in walk order: the stops whose surface is +/// 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 stops, void (*on_done)(void* user), void* done_user); /// Moves `delta` stops, finishing the walk (and calling `on_done`) on the way past the last one. diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 4fe26e7..6dd3b91 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -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** -- -/// 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) { +/// the frame a screenshot is cropped in -- with the window's own record of the sidebar page beside +/// them, so a stop that had to change the page to find its target is readable as such. `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. `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 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) && + const bool have_target = t != nullptr && gtk_widget_get_mapped(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); @@ -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; }; 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", - 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, + probe, what, tour_step_index(a) + 1, tour_step_count(a), tour_active(a) ? 1 : 0, + a.side_tab.c_str(), 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); @@ -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 // 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"); + tour_geometry_line(*a, "helpui", "step"); if (step == 4) { // Next, which is what a user presses to walk the tour. tour_step(*a, 1); @@ -4774,5 +4777,113 @@ gboolean helpui_probe_step(gpointer user) { 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(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 udpscope diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index ed8e6a8..43a1816 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -76,10 +76,42 @@ const TourStop* current_stop(const TourView& view) { return &view.plan[static_cast(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. void show_step(TourView& view) { const TourStop* stop = current_stop(view); 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 // 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; @@ -170,8 +202,16 @@ void tour_build(TourView& view, GtkWidget* overlay) { void tour_begin(TourView& view, std::vector stops, void (*on_done)(void* user), void* done_user) { if (view.overlay == nullptr || view.card == nullptr) return; - view.plan = std::move(stops); - if (view.plan.empty()) return; + // The walk is the stops whose surface is on screen *or* can be selected into view, filtered here + // 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 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.done_user = done_user; view.index = 0; @@ -228,7 +268,10 @@ void tour_update(TourView& view) { // one-shot measurement would leave the hole in the corner. GtkWidget* target = tour_target(view); 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)) { // 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 @@ -331,29 +374,33 @@ namespace { /// point of re-measuring every frame is that nothing here knows where the widget is. struct TourStep { 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* body; GtkWidget* App::* target; }; 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 " "zooms, a drag pans, and a right-click on a row opens that plot's own menu.", &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 " "holds the trigger and the list of signals. The buttons in the bar above show and hide it.", &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: " "the view then freezes on the first event that matches, however rare it is.", &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 " "switched on before it can be plotted, measured or searched.", &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 " "screen and the buffer holding it. The information button opens the counters behind it.", &App::status_row}, @@ -375,17 +422,18 @@ void start_tour(App& app) { app.settings.tour_seen = true; (void)save_settings(app.settings); } - // The stops 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. + // The sidebar's own stack, so a stop can name the page it stands on. Set here and not in + // `build_tour()`: the overlay is inserted above the window's content *before* `App::side_stack` is + // 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 stops; + stops.reserve(kTourStepCount); for (int i = 0; i < kTourStepCount; ++i) { GtkWidget* target = app.*(kTourSteps[i].target); - if (target != nullptr && gtk_widget_is_visible(target)) { - stops.push_back({target, kTourSteps[i].title, kTourSteps[i].body}); - } + stops.push_back({target, kTourSteps[i].page, kTourSteps[i].title, kTourSteps[i].body}); } - if (stops.empty()) return; tour_begin(app.tour, std::move(stops), nullptr, nullptr); }