diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 48d9243..659ff6e 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -489,7 +489,7 @@ Two lines of the self-check output are assertions in disguise: column and says there is one scroller and no stack. These are captured on the tick or at the exit probe: the dump runs after the window is destroyed. A source with extra panels adds *no* dock section: its panels are slide-over pages (`source-`), so the line reads the same on the E-1608 and the probe kind. -- `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=5 step=0 auto=1 requested=0` (in the exit +- `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=6 step=0 auto=1 requested=0` (in the exit dump, beside `dock:`) — the help affordance layer as the last tick found it. **`hints=` must equal the number of ids**, and the ids are the surfaces the contextual help is on: eleven of the window itself, and `math` only while the math editor is open (its heading lives in a builder that is rebuilt @@ -534,14 +534,18 @@ Two lines of the self-check output are assertions in disguise: [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). The writer chains to `g_log_writer_default()`, so every message still reaches stderr exactly as before and the documented baselines are read the same way. -- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… side_stack=… window_button=… status_label=… header_bar=… plot=…` (in the exit +- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… badge_bar=… dock=… window_button=… status_label=… header_bar=… plot=…` (in the exit dump, beside `help layer:`) — the **window-coordinate** rectangle of each surface the help's annotated figures label, `x,y,width,height`, or `-1,-1,0,0` for a surface that is **not on screen**. It is measured on the tick while the window is alive (nothing in the dump may call `gtk_widget_*` — see - [verification.md](verification.md)) and with `gtk_widget_is_visible()`, not `get_visible()`: a widget - under a hidden parent — `trigger_summary` while the pane is in *configuration* mode, the Measure page - while the Signals tab is showing — keeps its own flag true and reads `0x0`, and a crop box drawn from - that is a box over the wrong thing. The figures are produced by cropping a real screenshot, so these + [verification.md](verification.md)) and with `gtk_widget_is_visible()` **and** `gtk_widget_get_mapped()`, + not `get_visible()`: a widget under a hidden parent — `trigger_summary` while the pane is in + *configuration* mode, and **every `trigger_*` surface while the slide-over is closed** (a revealer + unmaps its child without touching its visible flag) — keeps its own flag true and reads `0x0`, and a crop + box drawn from that is a box over the wrong thing. So the three `trigger_*` rectangles read real numbers + only while the `trigger` page is open (the exit probe has it open; `UDPSCOPE_PROBE_HELPUI_MS` opens it for + its figures legs), and `-1,-1,0,0` otherwise. `badge_bar` is the badge row under the plot and `dock` the + right-hand column (these replaced `side_stack`, the old sidebar stack). The figures are produced by cropping a real screenshot, so these numbers **are** the crop boxes; their values depend on the window's size, which under the documented `xvfb-run` is the window's 756x894 minimum (the 640x480 screen is smaller than the content), so a run on a larger screen reads different numbers for the same panels. `trigger_summary` reading @@ -878,7 +882,7 @@ two also catch a row whose accelerator went missing. `settings=` must be a real every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores it afterwards. -- `[helpui] auto_started=1 visible=1 steps=5 step=0 (first run)` (stderr, `UDPSCOPE_PROBE_HELPUI_MS`) — +- `[helpui] auto_started=1 visible=1 steps=6 step=0 (first run)` (stderr, `UDPSCOPE_PROBE_HELPUI_MS`) — the help affordance layer end to end, in ten steps 300-400 ms apart (every reading is an *allocation*, and a step that read its own predecessor's geometry would read a rectangle from before the frame that placed it). The line above is the **first-run auto-start**, read before the probe @@ -910,21 +914,25 @@ two also catch a row whose accelerator went missing. `settings=` must be a real affordances are *gone*, not greyed, so anything else is a row that hid its buttons instead of removing them. The count comes back to the same twelve, 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 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 slideover=none spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` + - `[helpui] tour started from the probe: visible=1 steps=6 step=0` — the tour started the way + `Ctrl+T` starts it. `steps=6` is the plan: the table's six stops (plot, badges, trigger, dock, + signals, status) whose surface is on screen or can be opened into view. A stop that names a + slide-over page is always kept (the `on_page` hook opens it whatever the dock is doing), a target + that is mapped is kept, and a stop in a *collapsed dock* is dropped (`gtk_widget_get_mapped()`; see + the surface bullets in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). + - `[helpui] step=1/6 visible=1 slideover=none spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C` — **in window coordinates**, the frame a screenshot is cropped in, with the slide-over's open page - (`none`, or a page id) beside it; see `[toursurface]` below. + (`none`, or a page id) beside it; see `[toursurface]` below. The `target=` rectangle is the part of the + target that is *on screen* (intersected with the scrolled windows above it, `tour_target_bounds()`): a + form taller than the panel's scroller on a short window is spotlit down to the scroller's edge and no + further. **`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`, + bug. The badges step reads `clear=1`. + - `[helpui] after Next: step=2/6 visible=1`, `[helpui] after Back: step=1/6 visible=1`, `[helpui] after Skip: visible=0 dims_hidden=1 spot_hidden=1 card_hidden=1 controllers=12 (was 12) click_gestures=2` — the three controls. **`controllers=` equal to `(was N)` is the "no lingering input grab" reading**: a tour of ordinary overlay widgets takes no grab at all, and a count that @@ -935,7 +943,9 @@ two also catch a row whose accelerator went missing. `settings=` must be a real trigger pane's modes, by clicking its [edit] button. This exists because one of the help's figures is the *locked* pane's summary grid (`trigger_summary`), which in configuration mode is not on screen at all and reads `-1,-1,0,0`; the two lines give the geometry agent both states' numbers - rather than leaving them to find the mode. `trigger_panel`'s height differs between them (508 px in + rather than leaving them to find the mode. The probe opens the slide-over's `trigger` page for these + legs (and for the collapsed-search leg after them) and closes it at the end: with the panel shut + every `trigger_*` reads `-1,-1,0,0`. `trigger_panel`'s height differs between them (532 px in configuration mode against 72 locked — the body is replaced by the summary), which is the check that the mode really moved. - `[helpui] collapsed search: expanded=0 mapped=1 mapped_header=1 hint=12x12` (the last step, in @@ -945,28 +955,40 @@ two also catch a row whose accelerator went missing. `settings=` must be a real read `mapped=0` (and `0x0`), which is exactly why the header box exists (`search_head` in `src/ui/trigger-panel.ui`) and why the hint joins it as its last child. `mapped_header=` is the header box beside it, and `hint=WxH` is the same 12x12 the layer's own line reads. -- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) -- a stop that stands on a surface it is - not on, entered from the state it is not in. Since the sidebar's tabs became the dock the probe opens the - `trigger` slide-over page instead of switching a tab, starts the window's tour and walks it a stop per tick - (400 ms). **The tour's own rework for the slide-over is Task 5's**: until its page hook exists the tour - has no page stack, so a stop whose target is on a slide-over page is dropped, and the lines below read the - dock only. The lines: +- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) -- a stop that opens a surface it is not + on, entered from the state it is not in. The probe opens the `trigger` slide-over page, starts the + window's tour from there and walks the whole table a stop per tick (400 ms, past the panel's 120 ms + slide); the `on_page` hook is what opens the page for the trigger stop and closes the panel for every + stop that names none. The lines: - - `[toursurface] before: slideover=none signal_list_mapped=1 running=1` -- the state a first run starts in - (the dock is on screen), then the auto-started walk is dismissed. - - `[toursurface] on trigger: slideover=trigger signal_list_mapped=1 side_box_mapped=1` -- a frame after the - page was opened: the dock is still on screen under the slide-over (they are not alternatives). - - `[toursurface] tour: visible=1 steps=N step=0 signals_stop=1` -- **`signals_stop=1` is the filter's - reading**: the signal list is in the dock, which is mapped, so its stop is in the plan. - - `[toursurface] step=N/M slideover= target_mapped=D[ target=signal_list]` -- one per stop; - **the `signals` stop reads `target_mapped=1`**. - - `[toursurface] signals stop=N/M visible=1 slideover=… spot=[…] target=[…] contains=1 card=[…] clear=1` -- - the `tour_geometry_line()` the helpui probe uses: **`contains=1` is the assertion** that the spotlight - and the card are on the list. - - `[toursurface] collapsed: side_box_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 and the stops that need the - dock are dropped while the page-less ones stay. This is what says a *collapsed pane* is dropped. + - `[toursurface] before: slideover=none signal_list_mapped=1 running=1` -- the state a first run starts + in (panel closed, dock on screen); the auto-started walk is dismissed. + - `[toursurface] on trigger: slideover=trigger trigger_body_mapped=1 side_box_mapped=1` -- a frame after + the page was opened: the dock is still on screen under the panel (they are not alternatives). + - `[toursurface] tour: visible=1 steps=6 step=0 trigger_stop=1 signals_stop=1` -- the plan: **six + stops, the trigger stop and the signals stop both in it**. + - `[toursurface] step=N/6 title='…' slideover= target_mapped=D` -- one per stop, in order: *The + plot*, *The badges*, *The trigger*, *The dock*, *The signals*, *The status line*. **Step 1 reads + `slideover=none` although the probe started with the panel open** (a stop with no page closes it), + **step 3 reads `slideover=trigger`, and step 4 reads `slideover=none` again**; every `target_mapped` + is 1. + - `[toursurface] stop=N/6 visible=1 slideover=… spot=[…] target=[…] contains=1 card=[…] clear=C` -- the + `tour_geometry_line()` the helpui probe uses, one per stop, read a frame after the stop was entered + (the spotlight is withheld until the panel has finished sliding in, `page_settled`): **`contains=1` on + every stop is the assertion** that the spotlight is on the target. `clear=0` is read as geometry, not + as a placement bug, on the plot stop (the target is the whole content) and on the trigger stop at the + documented 756 px window (the panel is 360 px of it, so the card has no side wide enough and goes flush + left over the plot, overlapping the panel's edge by a few pixels). + - `[toursurface] finished: running=0 slideover=none` -- the last Next ended the walk and **the panel is + closed with it**. + - `[toursurface] restarted at trigger: running=1 slideover=trigger` then + `[toursurface] skipped: running=0 slideover=none` -- the walk parked on the trigger stop and dismissed + (the Skip/Esc route): **`slideover=none` after the dismissal** is the tour not leaving a panel behind. + - `[toursurface] collapsed: side_box_mapped=0 steps=4 trigger_stop=1 signals_stop=0 side_stop=0` -- the + filter with the dock put away (`sidebar_wanted` off; `update_side_visibility()` re-asserts it, so this + is how a probe puts the dock away for good): the dock's two stops are dropped, **and the trigger stop is + kept** because the slide-over does not depend on the dock -- four stops remain (plot, badges, trigger, + status). - `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: dock 0->243 (cursors_y=243 reach=2256 at_cursors=1) | g: dock 243->0 at_top=1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** (`g` returns, so its pair reads `243->0`) and **`refused` must be 1**. The `m`/`g` readings are the dock scroller's vertical adjustment before and after (the probe pads the dock to 3000 px so there is something to scroll, and undoes it at the end): **`m` must land on `cursors_y`, the cursors panel's y in the dock (`at_cursors=1`), and `g` must return to 0 (`at_top=1`)**, which is what says those two keys scroll to a section rather than toggling a pane; a `reach` of 0 would mean the dock fit its viewport and the probe's padding was lost; `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 090b15d..e995b36 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -639,11 +639,13 @@ as distinct from the fault injection below, which exercises ingest: 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 opens a slide-over page (`trigger`), - 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 … slideover=none … contains=1` and - `[toursurface] collapsed: side_box_mapped=0 steps=2 signals_stop=0` are the two assertions; its + starts the window's tour from that state and walks all six stops a stop per tick, reading the + slide-over's open page and the spotlight/card at each. The trigger stop must read `slideover=trigger` and + the next `slideover=none`; the first stop closes the panel the probe left open, and the end of the walk + (and a dismissal on the trigger stop) leave none. It then puts the dock *away* and starts the tour again: + the dock's stops are dropped while the trigger stop, which does not depend on the dock, stays. + `[toursurface] stop=3/6 … slideover=trigger … contains=1` and + `[toursurface] collapsed: side_box_mapped=0 steps=4 trigger_stop=1 signals_stop=0` are the 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 diff --git a/src/ui_app.hpp b/src/ui_app.hpp index 5949134..2e17cff 100644 --- a/src/ui_app.hpp +++ b/src/ui_app.hpp @@ -73,9 +73,11 @@ enum class EventGroupKind { /// 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. + /// The page the target lives on, or nullptr when the stop needs none. What a page *is* depends on + /// the view: a named page of its `page_stack` (the chooser), or whatever its `on_page` hook opens + /// (the main window: a slide-over page id). 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. A stop with no page *closes* what an `on_page` hook opened. const char* page; const char* title; const char* body; @@ -103,6 +105,16 @@ struct TourView { /// 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; + /// The alternative to `page_stack` for a surface that is not a stack: when set it *replaces* the + /// stack mechanism for this view, and is called with each stop's `page` as the stop is entered -- + /// `nullptr` meaning "no page needed" -- and with `nullptr` as the walk ends. The main window's opens + /// and closes the slide-over through it; the chooser keeps `page_stack`. + void (*on_page)(void* user, const char* page) = nullptr; + void* page_user = nullptr; + /// Optional, with `on_page`: whether the surface `on_page` opened has finished appearing. A stop that + /// names a page is not spotlit while this answers false -- a panel that is still sliding in is a + /// target that moves, and a spotlight that follows it for 120 ms reads as a glitch. Null means "ready". + bool (*page_settled)(void* user) = 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; @@ -2114,6 +2126,10 @@ void tour_end(TourView& view); void tour_update(TourView& view); /// The widget the current stop points at, or nullptr when the walk is not on a stop. GtkWidget* tour_target(TourView& view); +/// The part of `target` that is on screen, in `overlay`'s coordinates: its bounds intersected with every +/// scrolled window above it. False when it cannot be measured yet or is scrolled out of sight entirely. +/// The spotlight is drawn from it, and the probe reads the spotlight against it. +bool tour_target_bounds(GtkWidget* target, GtkWidget* overlay, graphene_rect_t* out); /// Builds the *window's* tour inside `app.tour.overlay`. Called once, as the overlay is inserted. void build_tour(App& app); diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index bd6af31..4930420 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -6657,8 +6657,8 @@ 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 -- 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 frame a screenshot is cropped in -- with the slide-over's open page beside them (`none` when it +/// is closed), so a stop that had to open a 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. @@ -6670,7 +6670,7 @@ void tour_geometry_line(App& a, const char* probe, const char* what) { 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_get_mapped(t) && - gtk_widget_compute_bounds(t, a.window, &target); + tour_target_bounds(t, a.window, &target); const bool have_card = a.tour.card != nullptr && gtk_widget_compute_bounds(a.tour.card, a.window, &card); const auto contains = [](const graphene_rect_t& outer, const graphene_rect_t& inner) { @@ -6859,6 +6859,10 @@ gboolean helpui_probe_step(gpointer user) { // The trigger pane's summary grid is on screen only in *locked* mode, and one of the help's // figures is that content: the probe reads `help figures:` in both states, so the geometry agent // has the locked pane's numbers without having to guess how to get there. + // The trigger form is a slide-over page: it is open for both readings (and for the collapsed-search + // one after them), because a closed panel leaves every one of its surfaces unmapped and reads + // `-1,-1,0,0`. The 400 ms of the step is past the 120 ms reveal. + if (step == 6) slideover_open(*a, "trigger"); if (a->trigger_edit != nullptr) g_signal_emit_by_name(a->trigger_edit, "clicked"); helpui_probe_next(*a, step == 6 ? 7 : 9, 400); return G_SOURCE_REMOVE; @@ -6891,6 +6895,7 @@ gboolean helpui_probe_step(gpointer user) { hint != nullptr && gtk_widget_get_mapped(hint) ? 1 : 0, gtk_widget_get_mapped(a->trigger_search) ? 1 : 0, sw, sh); } + slideover_close(*a); // opened by step 6 std::fflush(stderr); return G_SOURCE_REMOVE; } @@ -6898,7 +6903,7 @@ gboolean helpui_probe_step(gpointer user) { // --- 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. +/// than merely reordered, so the 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; @@ -6912,27 +6917,34 @@ 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. +/// The slide-over's open page for a reading: its id, or `none`. +const char* slideover_name(const App& a) { + return slideover_page(a).empty() ? "none" : slideover_page(a).c_str(); +} + +/// `UDPSCOPE_PROBE_TOUR_SURFACE_MS`: the stop that opens a surface it is not on. It opens the +/// slide-over's `trigger` page, starts the window's tour from that state, and walks the whole table a +/// stop per tick -- a tick apart on purpose (400 ms, longer than the panel's 120 ms slide), because a +/// reading taken in the switch's own tick would be of the surface the walk has just left (and +/// `update_tour()` places the hole from the frame that *has* been laid out). Every stop prints its +/// title, the slide-over's open page and whether the target is mapped, with the spotlight geometry +/// `[helpui]` prints: **`slideover=trigger` at the trigger stop and `slideover=none` at the one after +/// it, with `contains=1`**, is the reading that the walk opened the panel and then closed it. The walk +/// then runs again and is dismissed on the trigger stop (the Skip/Esc route), which must close the +/// panel too. It finally puts the dock *away* -- `sidebar_wanted` is the wish the tick re-asserts -- +/// and starts the tour once more: the dock's stops are dropped (a collapsed pane is not walked over) +/// while the trigger stop, which does not depend on the dock, is kept. 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. + // Where the run starts: the panel closed and the dock on screen. The auto-start's walk (if one + // began) would only confuse the reading; it goes first. The panel is then opened, so the walk's + // first stop (which needs no page) has something to close. std::fprintf(stderr, "[toursurface] before: slideover=%s signal_list_mapped=%d running=%d\n", - slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), mapped(a->signal_list), tour_active(*a) ? 1 : 0); + slideover_name(*a), mapped(a->signal_list), tour_active(*a) ? 1 : 0); tour_dismiss(*a); slideover_open(*a, "trigger"); a->dbg_tour_surface_step = 1; @@ -6940,15 +6952,16 @@ gboolean tour_surface_probe_step(gpointer user) { 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. + // A frame after the page opened, so the panel has slid in. The tour is started *here*, from this + // state. std::fprintf(stderr, - "[toursurface] on trigger: slideover=%s signal_list_mapped=%d side_box_mapped=%d\n", - slideover_page(*a).empty() ? "none" : slideover_page(*a).c_str(), - mapped(a->signal_list), mapped(a->side_box)); + "[toursurface] on trigger: slideover=%s trigger_body_mapped=%d side_box_mapped=%d\n", + slideover_name(*a), mapped(a->trigger_body), mapped(a->side_box)); start_tour(*a); - std::fprintf(stderr, "[toursurface] tour: visible=%d steps=%d step=%d signals_stop=%d\n", + std::fprintf(stderr, + "[toursurface] tour: visible=%d steps=%d step=%d trigger_stop=%d signals_stop=%d\n", tour_active(*a) ? 1 : 0, tour_step_count(*a), tour_step_index(*a), + tour_plan_holds(*a, a->trigger_body) ? 1 : 0, tour_plan_holds(*a, a->signal_list) ? 1 : 0); a->dbg_tour_surface_step = 2; tour_surface_probe_next(*a, 400); @@ -6956,44 +6969,53 @@ gboolean tour_surface_probe_step(gpointer user) { } 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 slideover=%s target_mapped=%d%s\n", - tour_step_index(*a) + 1, tour_step_count(*a), - slideover_page(*a).empty() ? "none" : slideover_page(*a).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); + // The last Next ended the walk: the panel must have gone with it. + std::fprintf(stderr, "[toursurface] finished: running=0 slideover=%s\n", slideover_name(*a)); + start_tour(*a); + while (tour_active(*a) && tour_target(*a) != a->trigger_body) tour_step(*a, 1); a->dbg_tour_surface_step = 3; tour_surface_probe_next(*a, 400); return G_SOURCE_REMOVE; } + GtkWidget* target = tour_target(*a); + const TourStop& stop = a->tour.plan[static_cast(tour_step_index(*a))]; + std::fprintf(stderr, "[toursurface] step=%d/%d title='%s' slideover=%s target_mapped=%d\n", + tour_step_index(*a) + 1, tour_step_count(*a), stop.title, slideover_name(*a), + mapped(target)); + // 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 stop's target. + tour_geometry_line(*a, "toursurface", "stop"); 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; + // The walk again, parked on the trigger stop with the panel open: Skip (or Esc) closes it. + std::fprintf(stderr, "[toursurface] restarted at trigger: running=%d slideover=%s\n", + tour_active(*a) ? 1 : 0, slideover_name(*a)); + tour_dismiss(*a); + std::fprintf(stderr, "[toursurface] skipped: running=%d slideover=%s\n", + tour_active(*a) ? 1 : 0, slideover_name(*a)); a->dbg_tour_surface_step = 4; + tour_surface_probe_next(*a, 100); + return G_SOURCE_REMOVE; + } + case 4: { + // 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 dock 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 = 5; tour_surface_probe_next(*a, 700); return G_SOURCE_REMOVE; } default: { start_tour(*a); std::fprintf(stderr, - "[toursurface] collapsed: side_box_mapped=%d steps=%d signals_stop=%d " - "side_stop=%d\n", + "[toursurface] collapsed: side_box_mapped=%d steps=%d trigger_stop=%d " + "signals_stop=%d side_stop=%d\n", mapped(a->side_box), tour_step_count(*a), + tour_plan_holds(*a, a->trigger_body) ? 1 : 0, tour_plan_holds(*a, a->signal_list) ? 1 : 0, tour_plan_holds(*a, a->side_box) ? 1 : 0); tour_dismiss(*a); diff --git a/src/ui_tour.cpp b/src/ui_tour.cpp index 4aea78b..21db85b 100644 --- a/src/ui_tour.cpp +++ b/src/ui_tour.cpp @@ -16,9 +16,9 @@ // (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`), and a window // resize moves it under a spotlight that a one-shot measurement would leave behind. // -// There are *two* tours and one implementation. The main window's walks the plot, the side panel, -// the trigger and the like; the source chooser's -- which appears *before* `App` exists -- walks the -// chooser's own widgets and then hands the tour on to the window through `Config::tour_pending`. The +// There are *two* tours and one implementation. The main window's walks the plot, the badge bar, the +// trigger (in the slide-over), the dock and the like; the source chooser's -- which appears *before* +// `App` exists -- walks the chooser's own widgets and then hands the tour on to the window through `Config::tour_pending`. The // machinery below is written against a `TourView` and a list of `TourStop`s, so the difference // between the two is only which widgets they name: the chooser keeps its table in // `source_dialog.cpp`, where its widgets live. @@ -76,12 +76,18 @@ 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. +/// Selects a stop's page before anything is measured against the target. Two mechanisms, one per +/// kind of view: a view with an `on_page` hook (the main window: its pages are slide-over pages, and +/// a stop with no page *closes* the panel) hands the page to the hook; a view with a `page_stack` (the +/// source chooser: an `AdwViewStack` whose page names are the ones its `.ui` names its pages with) +/// has the machinery select it. 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.on_page != nullptr) { + view.on_page(view.page_user, page); + return; + } 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); @@ -91,13 +97,16 @@ void select_view_page(TourView& view, const char* page) { /// 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: +/// cases apart: +/// * a stop that names a page of a view with an `on_page` hook is always walkable: the hook opens +/// the page whatever the rest of the window is doing (the slide-over does not depend on the dock); /// * 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 (stop.page != nullptr && view.on_page != nullptr) return true; if (gtk_widget_get_mapped(stop.target)) return true; return stop.page != nullptr && view.page_stack != nullptr && gtk_widget_get_mapped(view.page_stack); @@ -130,6 +139,25 @@ void show_step(TourView& view) { } // namespace +bool tour_target_bounds(GtkWidget* target, GtkWidget* overlay, graphene_rect_t* out) { + if (target == nullptr || overlay == nullptr || out == nullptr) return false; + if (!gtk_widget_compute_bounds(target, overlay, out)) return false; + // A target taller than the scroller it stands in (a form on a short window) is clipped by that + // scroller: the part below its edge is not on screen, and a hole that kept going would spotlight + // whatever is under the scroller instead -- the badge bar, the status line. So the hole is the + // *visible* part, intersected with every scrolled window above the target. + for (GtkWidget* parent = gtk_widget_get_parent(target); parent != nullptr; + parent = gtk_widget_get_parent(parent)) { + if (!GTK_IS_SCROLLED_WINDOW(parent)) continue; + graphene_rect_t clip; + graphene_rect_t cut; + if (!gtk_widget_compute_bounds(parent, overlay, &clip)) continue; + if (!graphene_rect_intersection(out, &clip, &cut)) return false; + *out = cut; + } + return true; +} + // --- the machinery, shared by both windows ---------------------------------- void tour_build(TourView& view, GtkWidget* overlay) { @@ -241,6 +269,10 @@ void tour_advance(TourView& view, int delta) { } void tour_end(TourView& view) { + // A page the walk opened is the walk's to close: the panel must not be left standing over a window + // whose tour has ended (Skip, Esc, or the last Next). Only when a walk was actually running -- + // dismissing an idle tour must not close a panel the user opened. + if (view.running && view.on_page != nullptr) view.on_page(view.page_user, nullptr); view.running = false; view.index = 0; view.plan.clear(); @@ -271,8 +303,15 @@ void tour_update(TourView& view) { // 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)) { + bool target_present = target != nullptr && gtk_widget_get_mapped(target); + // A stop on a page waits for the page to finish appearing: until then the target is moving, and + // the frame is drawn as a stop with nothing to point at (everything dim, the hole empty). + const TourStop* stop = current_stop(view); + if (target_present && stop != nullptr && stop->page != nullptr && view.page_settled != nullptr && + !view.page_settled(view.page_user)) { + target_present = false; + } + if (target_present && tour_target_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 // dragged wide), and a negative rectangle is not something a margin can express. @@ -354,6 +393,13 @@ void tour_update(TourView& view) { } else if (hx >= cw + 2 * kGap) { cx = hx - cw - kGap; cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap)); + } else if (hx > w - (hx + hw) + kGap) { + // No side wide enough for the card, but the hole is at the right edge (the slide-over's trigger + // form on a narrow window: 276 px to its left against a 289 px card): the card goes flush left, + // overlapping the plot and as little of the target as there is no way to avoid, instead of + // sitting on the target's own corner. + cx = kGap; + cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap)); } else { // No clear side at all -- the target is nearly the whole window, which is the plot's step in a // window narrow enough for the split view to *overlay* the side panel rather than put it beside @@ -374,9 +420,9 @@ 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. + /// The slide-over page the target lives on, or nullptr when the stop needs none. A stop with a page + /// opens it; a stop without one closes the panel, so the card never points at the status line over + /// an open panel. The page ids are the slide-over's (`trigger`, `source-`, ...). const char* page; const char* title; const char* body; @@ -388,15 +434,20 @@ constexpr TourStep kTourSteps[] = { "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", 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", "signals", "The trigger", + {"badges", nullptr, "The badges", + "Setup is reached by the thing it configures. The Trigger badge opens the trigger form; the " + "Source badge opens the source's own controls. Click it again, press Esc or click the plot to " + "close the panel.", + &App::badge_bar}, + {"trigger", "trigger", "The trigger", "Describe an event -- a signal, a condition, a level and the window around it -- and arm it: " "the view then freezes on the first event that matches, however rare it is.", &App::trigger_body}, - {"signals", "signals", "The signals", + {"side", nullptr, "The dock", + "The dock beside the plot: the list of signals, then the cursors and the measurements. The " + "buttons in the bar above show and hide it.", + &App::side_box}, + {"signals", nullptr, "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}, @@ -407,6 +458,23 @@ constexpr TourStep kTourSteps[] = { }; constexpr int kTourStepCount = static_cast(sizeof(kTourSteps) / sizeof(kTourSteps[0])); +/// The tour's page hook: a page is a slide-over page, and no page closes the panel. +void tour_open_page(void* user, const char* page) { + App& app = *static_cast(user); + if (page != nullptr) { + slideover_open(app, page); + } else { + slideover_close(app); + } +} + +/// Whether the slide-over has finished sliding in. A closed panel has nothing to wait for. +bool tour_page_settled(void* user) { + const App& app = *static_cast(user); + if (app.slideover.revealer == nullptr || app.slideover.page.empty()) return true; + return gtk_revealer_get_child_revealed(GTK_REVEALER(app.slideover.revealer)) != FALSE; +} + } // namespace void build_tour(App& app) { @@ -422,9 +490,12 @@ void start_tour(App& app) { app.settings.tour_seen = true; (void)save_settings(app.settings); } - // The main window has no page stack any more (the dock is one scroller): `page_stack` stays null, so - // a stop's `page` selects nothing and a stop whose target is unmapped is dropped. The slide-over hook - // that lets a stop open the trigger page is the next step's. + // The main window has no page stack (the dock is one scroller): its pages are the slide-over's, and + // the view's `on_page` hook opens one (or closes the panel for a stop with no page), replacing the + // `page_stack` mechanism the chooser keeps. + app.tour.on_page = tour_open_page; + app.tour.page_settled = tour_page_settled; + app.tour.page_user = &app; // The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen. std::vector stops; stops.reserve(kTourStepCount); @@ -484,7 +555,8 @@ constexpr FigureSurface kFigureSurfaces[] = { {"trigger_summary", &App::trigger_summary}, {"trigger_panel", &App::trigger_panel}, {"signal_list", &App::signal_list}, - {"side_stack", &App::side_box}, + {"badge_bar", &App::badge_bar}, + {"dock", &App::side_box}, {"window_button", &App::window_button}, {"status_label", &App::status_label}, {"header_bar", &App::toolbar}, @@ -526,10 +598,11 @@ void capture_help_state(App& app) { GtkWidget* widget = app.*(surface.widget); graphene_rect_t r = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f); int x = -1, y = -1, width = 0, height = 0; - // `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger summary - // while the pane is in configuration mode, the Measure page while the Signals tab is showing -- - // keeps its own flag true and reads 0x0, which a crop box would take for a real rectangle. - if (widget != nullptr && gtk_widget_is_visible(widget) && + // `is_visible()` and `get_mapped()`, not `get_visible()`: a widget under a hidden parent -- the + // trigger summary while the pane is in configuration mode -- keeps its own flag true and reads + // 0x0, and so does everything on the slide-over while the panel is closed (a revealer unmaps its + // child without touching its visible flag); a crop box would take either for a real rectangle. + if (widget != nullptr && gtk_widget_is_visible(widget) && gtk_widget_get_mapped(widget) && gtk_widget_compute_bounds(widget, app.window, &r)) { x = static_cast(std::lround(r.origin.x)); y = static_cast(std::lround(r.origin.y));