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