help: a tinier (i), hints beside their controls, and the tour starting in the chooser

The contextual help becomes the affordance the ask names: a plain (i)
(`dialog-information-symbolic`) at **16x16**, and one that sits beside the
control it explains rather than only in a pane's heading.

- The mark: `GtkMenuButton` ignores `min-width` on the widget and a size
  request, so the size is made in `install_css()` on all three nodes (outer
  `menubutton`, its `button` subnode, the glyph). Measured 16x16 against the
  default 34x34; the dump prints it as `hint=WxH`, measured with
  `gtk_widget_compute_bounds()` on the first *visible* hint.
- More sites: `events_record` (the "record events" switch in the trigger
  panel), `trigger_advanced` (the pre-trigger share row), `signals_config`
  (the signals pane's hint line, wrapped in a box so a hint can join it).
  Hints join as siblings, so none can cover a switch, pencil or dropdown.
- The source chooser gets its own layer -- `chooser_sources` (a new heading
  above the saved-source list) and `chooser_waveform` (the waveforms action
  row) -- built with the one `context_help_button()` both windows use and
  re-synced by its Help menu's toggle. The form's `AdwEntryRow`s cannot host a
  sibling hint, so none is invented for the host/port fields.
- The tour is one implementation over two windows: `ui_impl::TourView` plus a
  list of `TourStop`s; each window keeps its own table. A walk that passes its
  last stop calls `on_done`, and the chooser's sets `Config::tour_pending`, so
  a first run is one tour that crosses from the chooser into the app. The
  chooser's tour auto-starts from `settings.tour_seen` exactly as the window's
  does, and its "Guided tour" row now starts *it* instead of only requesting
  the window's.
- Probes: `UDPSCOPE_PROBE_CHOOSER_TOUR=1` / `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`,
  the hint size in the `help layer:`/`[helpui] contextual on:` lines, and the
  chooser's hints in its `chooser ui:` line. Docs updated in
  invariants-gtk-and-chrome.md, probe-readings.md and verification.md.
This commit is contained in:
Martino Ferrari committed 2026-10-14 22:42:38 +02:00
1 parent 395c9caec3
commit 829176248a
14 files changed
+901 -293

No files matched your search

+63 -15
View File
@@ -193,13 +193,16 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
and this dialog binds none, so a key printed here would be documentation that does nothing. and this dialog binds none, so a key printed here would be documentation that does nothing.
*Documentation* calls `show_help()` straight from the chooser (the manual is another process and *Documentation* calls `show_help()` straight from the chooser (the manual is another process and
needs nothing of the app); *Enable contextual help* writes the settings file, which is what the app needs nothing of the app); *Enable contextual help* writes the settings file, which is what the app
reads when it starts a moment later; *Guided tour* cannot start anything — the tour is drawn on the reads when it starts a moment later, and **re-syncs this dialog's own "(i)" rows** through
app's own window — so it sets **`Config::tour_pending`**, the one value `main()` owns and hands to `chooser_help_sync()`; *Guided tour* starts a tour **of this window** -- the chooser's own walk --
and that walk's last stop sets **`Config::tour_pending`**, the one value `main()` owns and hands to
both `choose_source()` and `run_ui()` (`open_waveform` and the preferences' relative time cross the both `choose_source()` and `run_ui()` (`open_waveform` and the preferences' relative time cross the
same way). `on_activate()` schedules `tour_start_requested()` 1.5 s later, the same delay the same way). `on_activate()` schedules `tour_start_requested()` 1.5 s later, the same delay the
first-run auto-start uses, and **that callback is separate from `tour_autostart()` on purpose**: it first-run auto-start uses, and **that callback is separate from `tour_autostart()` on purpose**: it
sets `tour_requested`, not `tour_auto_started`, so `help layer:` says which of the two started the sets `tour_requested`, not `tour_auto_started`, so `help layer:` says which of the two started the
tour instead of conflating a user's request with a first run. window's half instead of conflating a user's request with a first run. The chooser's half has its own
entry points in `source_dialog.cpp` (`chooser_tour_begin`, `chooser_tour_autostart`), and *that* walk
is what a first run meets first -- see the tour bullets in the help-affordance section below.
- **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.** - **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.**
Putting the events pane in a paned is what makes its width the user's to drag -- but with the Putting the events pane in a paned is what makes its width the user's to drag -- but with the
position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column
@@ -537,27 +540,69 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
stack, the visible child is the view, and `gtk_popover_present()` re-measures a popover that is 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 already up. `UDPSCOPE_PROBE_MENUS_MS` walks the *visible page*, which is the one line the stack cost
the probe. the probe.
- **A "?" joins its surface's heading row as a sibling, not as an overlay child.** An overlay child - **A "(i)" joins its surface's 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 sits *on* whatever is under it -- and every panel heading already has a switch or a pencil in a
a corner. `src/ui_context_help.cpp` is a table of (id, title, body, shortcut, host) where the host is 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 the *row* -- a panel's heading usually, and the row of a specific control (the "record events"
has no heading: its "?" is an overlay child of the overlay the layout editor already uses, and its switch, the pre-trigger share) where that is the thing being explained. The one exception is the
corner is free. The icon is `dialog-question-symbolic` -- `help-about-symbolic` is *already* the plot, which has no heading: its "(i)" is an overlay child of the overlay the layout editor already
status bar's information button, and two "help" glyphs meaning different things on the same line is uses, and its corner is free. The icon is **`dialog-information-symbolic`** -- `help-about-symbolic`
the trap the seven app icons exist to avoid; the dump reads the name back through the theme for the is *already* the status bar's information button, and two "help" glyphs meaning different things on
same reason the pane [edit] buttons do. 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. `math_head` exists only while the math
editor is open, so that row is skipped when it is null.
- **A `GtkMenuButton` ignores both `min-width` and a size request; its subnodes are where a small
one is made.** The "(i)" is meant to be a *mark*, not a button, and the default is 34x34 (a 24 px
floor plus 5 px of padding from Adwaita's `button` rule). Measured on libadwaita 1.10: setting
`min-width`/`min-height` on the widget changed nothing, and neither did `gtk_widget_set_size_request()`
-- the menu button's own measure wins. Three selectors are needed, all in `install_css()`:
`menubutton.udpscope-hint` (the outer node), `menubutton.udpscope-hint > button` (its subnode) and
`... > button > image` (the glyph), with `min-width: 0; min-height: 0; padding: 0` -- and the result
is **16x16**, the glyph's own size, because a `min-width` is a floor and not a cap. (A class on the
outer node alone leaves the subnode at 34; a class on the subnode alone shrank the width to 16 and
left the height at 26.) The dump's `hint=` reading is that number, and it is measured with
`gtk_widget_compute_bounds()` on the first *visible* hint -- a hint inside a collapsed `GtkExpander`
is not in the widget-parent chain at all and reads 0x0, which would look like a mark shrunk to
nothing.
- **Off is gone, not greyed, and the buttons are found by a data key rather than kept in a list.** The - **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 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 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" 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()`, 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. which means the same thing for a box and for the plot's overlay. The button itself is
`ui_impl::hint_button()` reached either by the table's `context_help_sync()` or by
`context_help_button()` for a window with no `App` (the source chooser) -- one button, two callers,
so the two windows cannot drift into two affordances.
- **The guided tour takes no grab, and that is the whole reason it is an overlay.** Four dim - **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 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 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 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 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. later change can add back without noticing.
- **The tour is one implementation over two windows.** `ui_impl::TourView` holds the overlay children
and the walk's place in it (`tour_build`/`tour_begin`/`tour_advance`/`tour_end`/`tour_update`), and
what differs between the windows is only the *stops*: a `TourStop` names a widget the owner resolves,
so each window keeps its own table -- the main window's against `App` fields,
`source_dialog.cpp`'s `kChooserStops` against the `Chooser`'s. A walk that passes its last stop calls
the view's `on_done`; the window's passes null, the chooser's sets `Config::tour_pending`, which is
how the two halves are one tour. `App` carries its own `TourView` (`app.tour`), so the machinery is
compiled once and the probe reads either window's state by the same fields.
- **The chooser's tour needs an overlay over its *whole* window, and `AdwWindow`'s content is
replaced, not wrapped.** Two of the chooser's stops name header-bar buttons, and an overlay inside
the `AdwToolbarView`'s `content` would give them negative coordinates and clamp the hole to the top
edge. So the overlay *becomes* the window's content and adopts the toolbar view -- and the order
matters: `gtk_overlay_set_child()` refuses a child that still has a parent, so
`adw_window_set_content(win, overlay)` goes **first** (that call is what unparents the toolbar view)
and `gtk_overlay_set_child(overlay, content)` second. The builder's object list still owns the view,
so nothing is finalized in between. Getting the order wrong is a `Gtk-CRITICAL` and an empty
chooser, which is exactly what the first version of this did.
- **The chooser's hints are its own table because it runs before `App`.** An `AdwEntryRow` is not a
box and its editor is internal, so a hint cannot join the host/port fields as a sibling -- appending
it to the `AdwPreferencesGroup` would make it a row under the field rather than a mark beside it, so
no hint is invented for them and the group's own `description` carries that text. The two rows the
dialog *does* have -- the heading above the saved-source list and the waveforms page's action row --
carry `chooser_sources` and `chooser_waveform`, added with the same `context_help_button()` the
window uses and re-synced by its Help menu's toggle.
- **The hole is kept from the last frame that could measure the target.** `gtk_widget_compute_bounds()` - **The hole is kept from the last frame that could measure the target.** `gtk_widget_compute_bounds()`
answers nothing on a frame that lands between two layout passes, and letting the hole empty there answers nothing on a frame that lands between two layout passes, and letting the hole empty there
made the spotlight and the card jump to the corner and back; a *target that is not visible* is the made the spotlight and the card jump to the corner and back; a *target that is not visible* is the
@@ -567,8 +612,11 @@ The `Help` submenu, the contextual help's "?" and the guided tour, and the traps
- **The first-run tour is a `settings.toml` key (`tour_seen`), written when the tour *starts*.** Not - **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 "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 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 still seen it. It starts **in the source chooser** when one is shown (the chooser is the surface a
before anything points at it. first run meets first, and the window's own auto-start is unreachable until the chooser has returned
with a source) and in the window otherwise; either way it is a 1.5 s timer, so the window is up and
laid out before anything points at it. The chooser's walk writes the key itself and hands the second
half over by `Config::tour_pending`, so a first run is *one* tour that crosses the two windows.
- **The `help figures:` line is captured on the tick, like every other size the dump prints.** It names - **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 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 the window is destroyed -- a `gtk_widget_compute_bounds()` there is the "no `gtk_widget_*` call in the
+77 -18
View File
@@ -395,21 +395,32 @@ 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 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 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. 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 requested=0` (in the exit - `help layer: contextual=on hints=11 ids={events plot status cursors measures trigger trigger_advanced events_record signals signals_config window} icon=dialog-information-symbolic(ok) hint=16x16; tour=idle steps=5 step=0 auto=1 requested=0` (in the exit
dump, beside `switcher:`) — the help affordance layer as the last tick found it. **`hints=` must equal 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 the number of ids**, and the ids are the surfaces the contextual help is on: ten of the window
itself, and `math` only while the math editor is open (its heading lives in a builder that is rebuilt 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). The layer is **off by default** on every opening, so it is not one of the window's own). Three of the ten are not a pane's *heading*
but the row of a specific control — `events_record` (the "record events" switch in the trigger
panel), `trigger_advanced` (the pre-trigger share row) and `signals_config` (the signals pane's hint
line, whose box exists so a hint can join it) — which is the rule the layer follows: a hint is a
sibling of the thing it explains and never an overlay on it. The layer is **off by default**
(`contextual_help` in `settings.toml` is false for a missing file), so a fresh-config run reads (`contextual_help` in `settings.toml` is false for a missing file), so a fresh-config run reads
`contextual=off hints=0` and that line above is the state *after* the Help row was switched on; `contextual=off hints=0` and that line above is the state *after* the Help row was switched on;
`contextual=off` beside a non-zero count is the toggle not having removed its buttons — **off is `contextual=off` beside a non-zero count is the toggle not having removed its buttons — **off is
*gone*, not greyed**. **`icon=` must read `(ok)`**: a `?"` whose name Adwaita lacks is a *gone*, not greyed**. **`icon=` must read `(ok)`**: an "(i)" whose name Adwaita lacks is a
*blank* control that still works, the failure the pane [edit] buttons already paid for once. `tour=` *blank* control that still works, the failure the pane [edit] buttons already paid for once. The name
is `running`/`idle` with the plan's `steps=` and the current `step=`; `auto=1` is the first-run is `dialog-information-symbolic`, the plain information mark; `help-about-symbolic` is the status
auto-start having fired *in this process* — the flag is `tour_seen` in `settings.toml`, so a second run bar's ⓘ and a second meaning for it is what the app's own icons exist to avoid. `hint=WxH` is the
against the same config directory reads `auto=0` — and `requested=1` is the **chooser's** "Guided tour" size the **first visible** hint was allocated, measured the way every other rectangle here is
(`Config::tour_pending`) having been carried into this run instead, which is `auto=0` because it is not (`gtk_widget_compute_bounds()`), and **16x16 is the reading a "much tinier" mark must keep**: the
the auto-start. class zeroes the outer `menubutton` node, its `button` subnode and the glyph, which is what a
`GtkMenuButton` needs (its measure ignores both `min-width` on the widget and a size request) — the
default 34x34 is the number to compare against. Hints inside a collapsed expander are skipped, so the
number is never a hidden widget's 0x0. `tour=` is `running`/`idle` with the plan's `steps=` and the
current `step=`; `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` —
and `requested=1` is the **chooser's** tour having handed over (`Config::tour_pending`), which is
`auto=0` because the request came from the other window rather than from this process's first run.
- `gtk warnings: N` (in the exit dump, beside `help layer:`) — **GTK's own log at warning or above, - `gtk warnings: N` (in the exit dump, beside `help layer:`) — **GTK's own log at warning or above,
counted for the whole run, and it must read `0`.** It is counted through a log *writer* function counted for the whole run, and it must read `0`.** It is counted through a log *writer* function
because GTK is built with structured logging: its messages go through the writer and never through because GTK is built with structured logging: its messages go through the writer and never through
@@ -472,6 +483,50 @@ Two lines of the self-check output are assertions in disguise:
connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a
dialog built and destroyed as it is a state check. An empty list must print `picked nothing: dialog built and destroyed as it is a state check. An empty list must print `picked nothing:
the list is empty` rather than dereferencing a null row. the list is empty` rather than dereferencing a null row.
**The command that reaches the chooser at all is `UDPSCOPE_PROBE_PICK_MS`, and this is worth
saying because three earlier attempts found no chooser and no output:** with no `--server` and no
probe, the chooser runs its own nested `GMainLoop` and *blocks* — there is nothing to time it out
and no dump runs, so a bare `./build/udpscope` (bare or under a pty) sits there silently. The
working form, against the mock:
```sh
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \
./build/udpscope
```
(with `XDG_CONFIG_HOME` pointing at a directory holding at least one saved source, or the pick
finds an empty list and the run prints `picked nothing`). It is the same command the docs give
elsewhere with `--server` removed and `UDPSCOPE_PROBE_PICK_MS` added.
- `[sources] chooser ui: … hints=[N {ids}] hint=WxH` (stderr, `UDPSCOPE_PROBE_PICK_MS`) — the
chooser's frame *and* its help layer in one line. `hints=[2 {chooser_sources chooser_waveform}]`
is the layer **on** (`contextual_help` in the file the run reads) and `hints=[0 {}] hint=0x0` is
it **off**, which is the same "off is gone" rule the window's line reads, in the window that runs
before `App` exists. The two ids are the surfaces this dialog has a row for — the heading above
the saved-source list and the waveforms page's action row; the form's `AdwEntryRow`s are *not*
hosts (a row is not a box and its editor is internal, so a hint cannot join it as a sibling) and
the connection fields are explained by their group's `description` instead. `hint=WxH` is the
same 16x16 the window's line reads: the button is `ui_impl::context_help_button()`, the one
`udpscope-hint` affordance both windows draw.
- `[sources] tour: first run -- auto_started=1 in the chooser` then
`[sources] chooser tour: visible=1 steps=5 step=0` (stderr) — **a first run's tour starts in the
chooser**, on the chooser's own widgets, from the same `settings.tour_seen` the window's
auto-start reads and with the same 1.5 s delay. `steps=5` is the chooser's plan (the source list,
the two tabs, New host, the help menu and the preferences). Driven by the walk itself:
- `UDPSCOPE_PROBE_CHOOSER_TOUR=1` emits `::clicked` on the help menu's **Guided tour** row, which
now starts *this* walk rather than only requesting the window's: `[sources] chooser help: tour
started in the chooser`, then `[sources] chooser tour: visible=1 steps=5 step=0`, then a walking
`[sources] chooser tour: next -> step=N/5` per press, then `[sources] chooser tour: finished 5
stops -- the window continues it (tour_pending=1)`. **`tour_pending=1` and `visible=0` is the
handoff assertion** — the walk is over here and the request rides on the one `Config` value
`main()` hands to both windows.
- `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=<ms>` schedules the same walk on a timer, for reading a tour
**the run itself started** (the first-run auto-start) rather than one the probe started; it must
be later than 1500 ms. Paired with `UDPSCOPE_PROBE_PICK_MS` a few hundred ms later, the run then
shows the other half: the application's `help layer:` reads `tour=running … auto=0 requested=1`,
the window's tour having been started by `tour_start_requested()` rather than by the first-run
timer.
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once - **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
with a drop and a window change, then again with no interaction: the second run's dump with a drop and a window change, then again with no interaction: the second run's dump
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
@@ -685,12 +740,16 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
touches anything: `auto_started=1 visible=1` is the tour that started itself, and the parenthetical is 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* `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: 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}` — - `[helpui] contextual on: hints=11 ids={events plot status cursors measures trigger trigger_advanced events_record signals signals_config window} hint=16x16` —
the eight "?" affordances, by id. The layer's ninth surface, `math`, is not here: its host exists the eleven "(i)" affordances, by id, and the size the first visible one was allocated. Ten are the
only while the math editor window is open. **On a fresh config this reads `hints=0 ids={}`**, because window's own surfaces (three of them a specific control's row rather than a heading — see the
the layer is off by default; the example is the state after the row below was switched on, and a `help layer:` entry) and the eleventh is `math`, which is present only while the math editor
fresh run's `hints=0` beside a `settings.toml` with no `contextual_help` key is the default being window is open; it reads 10 with the editor shut. **On a fresh config this reads `hints=0 ids={}`
read rather than assumed. and `hint=0x0`**, because the layer is off by default; the example is the state after the row below
was switched on, and a fresh run's `hints=0` beside a `settings.toml` with no `contextual_help`
key is the default being read rather than assumed. **`hint=16x16` is the "much tinier" assertion**
— the default menu-button mark is 34x34 (see `help layer:`), and anything near that is the class
not reaching the outer `menubutton` node.
- `[helpui] main view: rows=7 help_row=1` and one `[helpui] entry N '<label>' accel='<key>' order=O` - `[helpui] main view: rows=7 help_row=1` and one `[helpui] entry N '<label>' accel='<key>' order=O`
per row of the **Help view** — `entry 0 'Back'` with no accelerator and then the four the submenu per row of the **Help view** — `entry 0 'Back'` with no accelerator and then the four the submenu
promises: `entry 1 'Keyboard shortcuts' accel='Ctrl+/'`, `entry 2 'Documentation' accel='F1'`, promises: `entry 1 'Keyboard shortcuts' accel='Ctrl+/'`, `entry 2 'Documentation' accel='F1'`,
@@ -701,10 +760,10 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
`toggle=` being the contextual-help row's check, which *is* `settings.contextual_help`. It reads `0` `toggle=` being the contextual-help row's check, which *is* `settings.contextual_help`. It reads `0`
on a fresh config (the default) and `1` on one where the setting is on; the probe's next step drives on a fresh config (the default) and `1` on one where the setting is on; the probe's next step drives
it both ways, which is why the example here is the `1` side. it both ways, which is why the example here is the `1` side.
- `[helpui] toggle: off -> hints=0 ids={}; on -> hints=8 ids={…}; setting=on` — the toggle's effect - `[helpui] toggle: off -> hints=0 ids={}; on -> hints=11 ids={…}; setting=on` — the toggle's effect
read both ways through the row itself. **`hints=0` with `off` is the assertion**: off means the read both ways through the row itself. **`hints=0` with `off` is the assertion**: off means the
affordances are *gone*, not greyed, so anything else is a row that hid its buttons instead of affordances are *gone*, not greyed, so anything else is a row that hid its buttons instead of
removing them. The count comes back to the same eight, which is the other half — a toggle that removing them. The count comes back to the same eleven, which is the other half — a toggle that
removed and could not restore is a one-way switch. removed and could not restore is a one-way switch.
- `[helpui] tour started from the probe: visible=1 steps=5 step=0` — the tour started the way - `[helpui] tour started from the probe: visible=1 steps=5 step=0` — the tour started the way
`Ctrl+T` starts it. `steps=5` is the plan: the table's five steps whose target was on screen `Ctrl+T` starts it. `steps=5` is the plan: the table's five steps whose target was on screen
+18 -5
View File
@@ -309,16 +309,19 @@ as distinct from the fault injection below, which exercises ingest:
- `UDPSCOPE_PROBE_HELPUI_MS` — the help affordance layer, end to end, in ten steps 300–400 ms apart: - `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 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 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 the Help submenu**, the **contextual-help toggle** with the count of "(i)" affordances on either side
of it, the tour **started from the probe** and stepped forwards, back and dismissed, and `help 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 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 [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 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 lingering input grab — a tour of ordinary overlay widgets takes none), and **`hint=16x16`** is the
size the shrunk "(i)" must keep (the default menu-button mark is 34x34). The chooser's own tour is
the other half and has its own entry points — see the **chooser** bullet below. Give it
`UDPSCOPE_EXIT_AFTER_MS=9500 UDPSCOPE_EXIT_DUMP=1` to get the `help layer:`/`help figures:` dump `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 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 `tour_seen` — **in the source chooser when there is one**, in the window otherwise (see the chooser
second and a half — and that start *writes* `tour_seen` to whichever config directory the run is bullet) — so a probe run against a fresh config directory will have it on screen for the first
second and a half, and that start *writes* `tour_seen` to whichever config directory the run is
using, which is why the second run against the same one reads `auto=0`. 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 - `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the
trigger panel: `[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01`. A trigger panel: `[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01`. A
@@ -331,7 +334,17 @@ as distinct from the fault injection below, which exercises ingest:
entry per configuration (`host:port/name … buffer= relative= note='…'`), and entry per configuration (`host:port/name … buffer= relative= note='…'`), and
`UDPSCOPE_PROBE_PICK_MS=800` opens the chooser, picks the first row and prints `UDPSCOPE_PROBE_PICK_MS=800` opens the chooser, picks the first row and prints
`[sources] picked host:port … buffer=64 relative=0 capture=0 config=… note='…'` — the settings the `[sources] picked host:port … buffer=64 relative=0 capture=0 config=… note='…'` — the settings the
configuration carried, into the `Config` the model is built from. A legacy file configuration carried, into the `Config` the model is built from. **With no `--server` and no probe
the chooser blocks in its own nested loop** (no frame to end and no dump), so a chooser run is
always this probe. It also prints the dialog's frame *and its own help layer*:
`[sources] chooser ui: … hints=[2 {chooser_sources chooser_waveform}] hint=16x16` with the setting
on, `hints=[0 {}] hint=0x0` with it off. `UDPSCOPE_PROBE_CHOOSER_HELP=1` drives the **Enable
contextual help** row and reads the file back; `UDPSCOPE_PROBE_CHOOSER_TOUR=1` drives the **Guided
tour** row and walks the chooser's own tour to its last stop, printing `[sources] chooser tour: …`
and the handoff (`tour_pending=1`); `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=<ms>` schedules the same walk,
which is how the **first-run** auto-start is read (schedule it after 1500 ms, and pair it with
`UDPSCOPE_PROBE_PICK_MS` a little later so the application's half follows). Its lines and what each
asserts are in [probe-readings.md](probe-readings.md). A legacy file
(`<host>_<port>.toml`) produces `layout loaded from …/<host>_<port>/default.toml`, an `active` (`<host>_<port>.toml`) produces `layout loaded from …/<host>_<port>/default.toml`, an `active`
holding `default`, and the old file renamed `.imported`. holding `default`, and the old file renamed `.imported`.
- **The loading window covers the gap, and only on the path that asked.** The same - **The loading window covers the gap, and only on the path that asked.** The same
+241 -16
View File
@@ -141,11 +141,40 @@ struct Chooser {
GtkWidget* help_tour = nullptr; GtkWidget* help_tour = nullptr;
GtkWidget* help_toggle = nullptr; GtkWidget* help_toggle = nullptr;
GtkWidget* help_popover = nullptr; GtkWidget* help_popover = nullptr;
/// The contextual help's "(i)" affordances, one per surface *this* window explains. They are not
/// the window's table -- `ui_context_help.cpp` names `App` fields, and there is no `App` here yet
/// -- so this window holds its own two rows and joins them with `context_help_button()`, which
/// draws the *one* "(i)" button both windows use.
GtkWidget* sources_head = nullptr;
GtkWidget* waves_actions = nullptr;
/// The guided tour of *this* window. It walks the chooser's own widgets and, when the walk
/// finishes, hands it on to the application through `Config::tour_pending`. The machinery is the
/// shared `ui_impl::TourView`; only the stops are this window's, because they name its widgets.
ui_impl::TourView tour;
/// The timer that auto-starts this tour on a first run, kept so a chooser that closes first can
/// cancel it (`&c` is a stack frame, and a timer that outlives it is the defect this project has
/// already paid for once).
guint tour_source = 0;
/// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`'s own timer, cancelled the same way.
guint tour_probe_source = 0;
}; };
void chooser_finish(Chooser& c, bool chosen) { void chooser_finish(Chooser& c, bool chosen) {
c.chosen = chosen; c.chosen = chosen;
// The two timers point into this stack frame (`&c`), so they go before the frame does. Both are
// cleared first: `g_source_remove()` on an id whose source already returned is a GLib critical.
if (c.tour_source != 0) {
g_source_remove(c.tour_source);
c.tour_source = 0;
}
if (c.tour_probe_source != 0) {
g_source_remove(c.tour_probe_source);
c.tour_probe_source = 0;
}
// The tour's own widgets go with the window; ending the walk first means the spotlight and the
// card are hidden rather than finalized by the destroy while still visible.
ui_impl::tour_end(c.tour);
// Destroy the form first: any path that reaches here while the form is up -- a row click, the // Destroy the form first: any path that reaches here while the form is up -- a row click, the
// Escape key -- would otherwise leave a window whose four handlers are bound to a dead stack // Escape key -- would otherwise leave a window whose four handlers are bound to a dead stack
// frame. `gtk_window_destroy()` rather than `gtk_window_close()`: the pointer is already // frame. `gtk_window_destroy()` rather than `gtk_window_close()`: the pointer is already
@@ -176,6 +205,10 @@ void chooser_populate_waves(Chooser& c);
void report_pick(const Chooser& c); void report_pick(const Chooser& c);
/// Defined below; the row buttons reach it, and the row builder sits above it. /// Defined below; the row buttons reach it, and the row builder sits above it.
void open_form(Chooser& c, int edit_index); void open_form(Chooser& c, int edit_index);
/// The chooser's own help surfaces and its own tour, defined below: the help menu's rows and the
/// first-run timer call them, and both need the window's widgets resolved first.
void chooser_help_sync(Chooser& c);
void chooser_tour_begin(Chooser& c);
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) { void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
Chooser& c = *static_cast<Chooser*>(user); Chooser& c = *static_cast<Chooser*>(user);
@@ -644,16 +677,18 @@ void build_chooser_help(Chooser& c) {
c.help_tour = ui_impl::menu_button("Guided tour", nullptr); c.help_tour = ui_impl::menu_button("Guided tour", nullptr);
gtk_widget_set_tooltip_text(c.help_tour, gtk_widget_set_tooltip_text(c.help_tour,
"walk over the plot, the side panel, the trigger, the signals and " "a walk over this dialog's own widgets, then over the plot, the side "
"the status line, once the window is up"); "panel, the trigger, the signals and the status line once a source "
"has been opened");
g_signal_connect(c.help_tour, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { g_signal_connect(c.help_tour, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
Chooser& ch = *static_cast<Chooser*>(user); Chooser& ch = *static_cast<Chooser*>(user);
gtk_popover_popdown(GTK_POPOVER(ch.help_popover)); gtk_popover_popdown(GTK_POPOVER(ch.help_popover));
// The tour lives on the window the app builds, which does not exist yet, so the // The tour *starts here*, on this window: the chooser is what a first run meets
// request rides on the `Config` this call was handed and the app starts it once // first and what it has no way of knowing about. When this walk finishes it sets
// the window is up -- see `Config::tour_pending`. // `Config::tour_pending`, so the application continues it once a source has been
ch.cfg->tour_pending = true; // picked -- see `chooser_tour_begin()` and `chooser_tour_done()`.
std::fprintf(stderr, "[sources] chooser help: tour requested\n"); chooser_tour_begin(ch);
std::fprintf(stderr, "[sources] chooser help: tour started in the chooser\n");
std::fflush(stderr); std::fflush(stderr);
}), }),
&c); &c);
@@ -661,14 +696,14 @@ void build_chooser_help(Chooser& c) {
// The check *is* the setting, exactly as in the window's menu: the row is stated before the handler // The check *is* the setting, exactly as in the window's menu: the row is stated before the handler
// is connected, so a row that opens checked does not write the file while the dialog is still being // is connected, so a row that opens checked does not write the file while the dialog is still being
// built. The chooser cannot re-sync anything (there is no "?" layer here), so the handler only has // built. The chooser re-syncs its own "(i)" layer here, the way the window re-syncs the window's.
// to leave the setting where the app will read it.
c.help_toggle = ui_impl::menu_toggle("Enable contextual help", nullptr, c.help_toggle = ui_impl::menu_toggle("Enable contextual help", nullptr,
load_settings().contextual_help); load_settings().contextual_help);
gtk_widget_set_tooltip_text(c.help_toggle, gtk_widget_set_tooltip_text(c.help_toggle,
"a small \u201c?\u201d beside each surface of the main window, saying " "a small \u201c(i)\u201d beside each surface of this dialog and of the "
"what that surface is for"); "main window, saying what that surface is for");
g_signal_connect(c.help_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* button, gpointer) { g_signal_connect(c.help_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* button, gpointer user) {
Chooser& ch = *static_cast<Chooser*>(user);
Settings s = load_settings(); Settings s = load_settings();
s.contextual_help = gtk_toggle_button_get_active(button) != FALSE; s.contextual_help = gtk_toggle_button_get_active(button) != FALSE;
// Written in full, the way the window's toggle writes it: the keys this row does // Written in full, the way the window's toggle writes it: the keys this row does
@@ -678,6 +713,7 @@ void build_chooser_help(Chooser& c) {
settings_path().c_str()); settings_path().c_str());
std::fflush(stderr); std::fflush(stderr);
} }
chooser_help_sync(ch);
}), }),
&c); &c);
gtk_box_append(GTK_BOX(box), c.help_toggle); gtk_box_append(GTK_BOX(box), c.help_toggle);
@@ -686,6 +722,159 @@ void build_chooser_help(Chooser& c) {
gtk_menu_button_set_popover(GTK_MENU_BUTTON(c.help_button), c.help_popover); gtk_menu_button_set_popover(GTK_MENU_BUTTON(c.help_button), c.help_popover);
} }
/// The chooser's contextual help: the same "(i)" the window draws, on the two surfaces *this*
/// window has a row for -- the list of saved sources and the waveforms page's action row. It is
/// called once after the window is built and again by the Help menu's toggle, which is the one place
/// the setting can change while the dialog is up.
///
/// The form's rows are deliberately not a host: an `AdwEntryRow` is not a box and its editing widget
/// is internal, so a hint could not join it as a sibling -- it would have to be appended to the whole
/// `AdwPreferencesGroup` as a row of its own, which is not a hint *beside* the field but a row under
/// it. The connection fields are therefore explained by the group's own `description`, which is
/// where libadwaita puts a hint that must survive being typed over.
void chooser_help_sync(Chooser& c) {
const bool on = load_settings().contextual_help;
ui_impl::context_help_button(
c.sources_head, "chooser_sources", "The sources you have opened",
"One row per saved configuration. Clicking one opens it: it becomes the active configuration "
"and the stream starts. The pencil edits a row's name, note, buffer size and clock; the cross "
"forgets it and deletes its saved session.",
"", on);
ui_impl::context_help_button(
c.waves_actions, "chooser_waveform", "Opening a recording",
"A `.npy` an earlier run exported is opened for viewing and analysis instead of a live source: "
"there is no acquisition behind it, so the trigger and the rate counters are not there -- only "
"the samples, which can be measured, searched and re-exported.",
"", on);
}
/// One stop of the *chooser's* tour. Its targets are this window's widgets, which exist before `App`
/// does -- which is the whole reason the stops cannot live in the window's table.
struct ChooserStop {
GtkWidget* Chooser::* target;
const char* title;
const char* body;
};
constexpr ChooserStop kChooserStops[] = {
{&Chooser::list, "Choosing a source",
"Every source you have saved, newest first. Clicking one opens it -- that is the choice this "
"dialog exists to make -- and the arrows on a row edit or forget its configuration."},
{&Chooser::switcher, "Two kinds of input",
"A *source* is a stream to receive; a *waveform* is a recording an earlier run exported. They "
"are the two answers to \u201cwhat should be on the screen\u201d, and these are the tabs "
"between them."},
{&Chooser::new_host, "A source that is not listed",
"Opens the form for an address that has no configuration yet: host, port, the settings a source "
"can only be given before it is opened, and the name its session will be saved under."},
{&Chooser::help_button, "The manual, and this tour",
"Documentation opens the manual in the desktop help viewer. This tour walks this window and "
"then the application's own window. Contextual help is the switch below it: on, every surface "
"carries a small \u201c(i)\u201d saying what it is for."},
{&Chooser::prefs_button, "Preferences",
"The event cache, relative time and the interface theme. This is the last moment relative time "
"can still change this run -- the buffers are planned as soon as a source is picked -- which is "
"why the button is here and not only in the window's menu."},
};
constexpr int kChooserStopCount = static_cast<int>(sizeof(kChooserStops) / sizeof(kChooserStops[0]));
/// The walk is over: the chooser hands the tour on to the application. This is the second half of the
/// two-window tour -- `tour_pending` is the value `main()` owns and hands to both `choose_source()`
/// and `run_ui()`, and the window starts its own tour from it once a source has been picked.
void chooser_tour_done(void* user) {
Chooser& c = *static_cast<Chooser*>(user);
c.cfg->tour_pending = true;
std::fprintf(stderr,
"[sources] chooser tour: finished %d stops -- the window continues it "
"(tour_pending=1)\n",
kChooserStopCount);
std::fflush(stderr);
}
/// "The tour has run" is the same `settings.tour_seen` the window's auto-start reads, written when
/// the walk *starts*: a user who skips it has still seen it.
void chooser_tour_mark_seen() {
Settings s = load_settings();
if (s.tour_seen) return;
s.tour_seen = true;
if (!save_settings(s)) {
std::fprintf(stderr, "[sources] tour: could not write %s: it will be offered again\n",
settings_path().c_str());
}
}
void chooser_tour_begin(Chooser& c) {
if (c.tour.card == nullptr) return;
chooser_tour_mark_seen();
// The stops whose target is on screen, in table order, the same filter the window's tour uses: a
// surface that is not there is not walked over.
std::vector<ui_impl::TourStop> stops;
for (int i = 0; i < kChooserStopCount; ++i) {
GtkWidget* target = c.*(kChooserStops[i].target);
if (target != nullptr && gtk_widget_is_visible(target)) {
stops.push_back({target, kChooserStops[i].title, kChooserStops[i].body});
}
}
if (stops.empty()) return;
ui_impl::tour_begin(c.tour, std::move(stops), chooser_tour_done, &c);
}
/// The tour is drawn over the *whole* window, header bar included: two of its stops name the header's
/// own buttons, and an overlay placed inside the content would give them negative coordinates and
/// clamp its hole to the top edge. `AdwWindow`'s content takes the overlay and the toolbar view
/// becomes its child -- the builder still owns the tree, so there is no reference bookkeeping.
void build_chooser_tour(Chooser& c) {
GtkWidget* content = adw_window_get_content(ADW_WINDOW(c.window));
if (content == nullptr) return;
// The overlay has to *become* the window's content before the toolbar view can be adopted by it:
// `gtk_overlay_set_child()` refuses a child that still has a parent, so the two calls are in this
// order -- handing the window the overlay first is what unparents the toolbar view. The view is
// held across the swap rather than left to whatever the builder's object list keeps: it has no id
// for the builder to name it by, and the window's content was its only owner.
GtkWidget* overlay = gtk_overlay_new();
g_object_ref(content);
adw_window_set_content(ADW_WINDOW(c.window), overlay);
gtk_overlay_set_child(GTK_OVERLAY(overlay), content);
g_object_unref(content);
ui_impl::tour_build(c.tour, overlay);
}
/// A first run is the run whose settings file does not say the tour has been offered -- the same key
/// the window's own auto-start reads. Delayed 1.5 s for the reason the window's is: the window has to
/// be up and laid out before anything points at it.
gboolean chooser_tour_autostart(gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
c.tour_source = 0;
std::fprintf(stderr, "[sources] tour: first run -- auto_started=1 in the chooser\n");
std::fflush(stderr);
chooser_tour_begin(c);
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`: the chooser's tour, read and walked the way the window's is --
/// where it stands, then every stop, then the handoff. It reports the walk by *stepping* it (the
/// button the user presses), so what it reads is what a first run sees, not the state the probe set.
gboolean chooser_tour_probe(gpointer user) {
Chooser& c = *static_cast<Chooser*>(user);
c.tour_probe_source = 0;
std::fprintf(stderr, "[sources] chooser tour: visible=%d steps=%d step=%d\n",
c.tour.running ? 1 : 0, static_cast<int>(c.tour.plan.size()), c.tour.index);
while (c.tour.running) {
const int before = c.tour.index;
ui_impl::tour_advance(c.tour, 1);
if (c.tour.running) {
std::fprintf(stderr, "[sources] chooser tour: next -> step=%d/%d\n", c.tour.index,
static_cast<int>(c.tour.plan.size()));
} else {
std::fprintf(stderr, "[sources] chooser tour: Next past stop %d finished the walk\n", before + 1);
}
}
std::fprintf(stderr, "[sources] chooser tour: after finish visible=%d tour_pending=%d\n",
c.tour.running ? 1 : 0, c.cfg->tour_pending ? 1 : 0);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does. /// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a /// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
/// dialog built, filled and destroyed is as much the point as the values, the same way /// dialog built, filled and destroyed is as much the point as the values, the same way
@@ -706,9 +895,14 @@ void report_chooser_ui(const Chooser& c) {
adw_view_stack_page_get_title(page), adw_view_stack_page_get_title(page),
adw_view_stack_page_get_icon_name(page) != nullptr ? "yes" : "NO"); adw_view_stack_page_get_icon_name(page) != nullptr ? "yes" : "NO");
} }
std::string hint_ids;
const int hints = ui_impl::context_help_count_in(c.window, &hint_ids);
int hint_w = 0;
int hint_h = 0;
ui_impl::context_help_size_in(c.window, &hint_w, &hint_h);
std::fprintf(stderr, std::fprintf(stderr,
" visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%d] " " visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%d] "
"help=[button=%d docs=%d tour=%d toggle=%d on=%d]\n", "help=[button=%d docs=%d tour=%d toggle=%d on=%d] hints=[%d {%s}] hint=%dx%d\n",
adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)), adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)),
adw_view_switcher_bar_get_stack(ADW_VIEW_SWITCHER_BAR(c.switcher)) == adw_view_switcher_bar_get_stack(ADW_VIEW_SWITCHER_BAR(c.switcher)) ==
ADW_VIEW_STACK(c.page_stack) ADW_VIEW_STACK(c.page_stack)
@@ -721,7 +915,8 @@ void report_chooser_ui(const Chooser& c) {
c.help_toggle != nullptr && c.help_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(c.help_toggle)) gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(c.help_toggle))
? 1 ? 1
: 0); : 0,
hints, hint_ids.c_str(), hint_w, hint_h);
std::fflush(stderr); std::fflush(stderr);
} }
@@ -741,9 +936,18 @@ gboolean pick_probe(gpointer user) {
// would assert nothing. The tour is read back out of the `Config` the run will be handed, which is // would assert nothing. The tour is read back out of the `Config` the run will be handed, which is
// the carrier; the toggle is read back out of the settings file it writes, since the app is built // the carrier; the toggle is read back out of the settings file it writes, since the app is built
// from that file and not from the dialog. // from that file and not from the dialog.
// `UDPSCOPE_PROBE_CHOOSER_TOUR=1` drives the help menu's Guided tour row the way a click does --
// which now starts a tour *in this dialog*, not merely a request for the window's -- then walks it
// to its last stop and past it, so the handoff is exercised, and then falls through to the pick
// below. The point of the tour entry is that it crosses into the run this chooser starts, so a
// probe that stopped here would assert nothing; the carrier is `Config::tour_pending`.
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_TOUR") != nullptr && c.help_tour != nullptr) { if (g_getenv("UDPSCOPE_PROBE_CHOOSER_TOUR") != nullptr && c.help_tour != nullptr) {
g_signal_emit_by_name(c.help_tour, "clicked"); g_signal_emit_by_name(c.help_tour, "clicked");
std::fprintf(stderr, "[sources] chooser help: tour_pending=%d\n", c.cfg->tour_pending ? 1 : 0); std::fprintf(stderr, "[sources] chooser tour: visible=%d steps=%d step=%d\n",
c.tour.running ? 1 : 0, static_cast<int>(c.tour.plan.size()), c.tour.index);
while (c.tour.running) ui_impl::tour_advance(c.tour, 1);
std::fprintf(stderr, "[sources] chooser tour: after finish visible=%d tour_pending=%d\n",
c.tour.running ? 1 : 0, c.cfg->tour_pending ? 1 : 0);
std::fflush(stderr); std::fflush(stderr);
} }
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_HELP") != nullptr && c.help_toggle != nullptr) { if (g_getenv("UDPSCOPE_PROBE_CHOOSER_HELP") != nullptr && c.help_toggle != nullptr) {
@@ -1024,9 +1228,12 @@ bool choose_source(Config& cfg) {
c.waves_open = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_open")); c.waves_open = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_open"));
c.cancel_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "cancel_button")); c.cancel_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "cancel_button"));
c.help_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "help_menu_button")); c.help_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "help_menu_button"));
c.sources_head = GTK_WIDGET(gtk_builder_get_object(c.ui, "sources_head"));
c.waves_actions = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_actions"));
if (c.window == nullptr || c.page_stack == nullptr || c.switcher == nullptr || c.list == nullptr || if (c.window == nullptr || c.page_stack == nullptr || c.switcher == nullptr || c.list == nullptr ||
c.wave_list == nullptr || c.prefs_button == nullptr || c.new_host == nullptr || c.wave_list == nullptr || c.prefs_button == nullptr || c.new_host == nullptr ||
c.waves_open == nullptr || c.cancel_button == nullptr || c.help_button == nullptr) { c.waves_open == nullptr || c.cancel_button == nullptr || c.help_button == nullptr ||
c.sources_head == nullptr || c.waves_actions == nullptr) {
std::fprintf(stderr, "[sources] chooser ui: source-chooser.ui is missing an id this code reads\n"); std::fprintf(stderr, "[sources] chooser ui: source-chooser.ui is missing an id this code reads\n");
std::fflush(stderr); std::fflush(stderr);
return false; return false;
@@ -1041,6 +1248,11 @@ bool choose_source(Config& cfg) {
ui_impl::install_css(); ui_impl::install_css();
build_chooser_help(c); build_chooser_help(c);
// The chooser's own "(i)" layer and its own tour, both over the widgets the block above resolved.
// The tour's overlay wraps the whole window, so it is built before the window is presented.
chooser_help_sync(c);
build_chooser_tour(c);
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c); g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
g_signal_connect(c.wave_list, "row-activated", G_CALLBACK(on_wave_activated), &c); g_signal_connect(c.wave_list, "row-activated", G_CALLBACK(on_wave_activated), &c);
g_signal_connect(c.waves_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c); g_signal_connect(c.waves_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c);
@@ -1078,6 +1290,19 @@ bool choose_source(Config& cfg) {
if (const char* env = g_getenv("UDPSCOPE_PROBE_PICK_MS")) { if (const char* env = g_getenv("UDPSCOPE_PROBE_PICK_MS")) {
c.pick_source = g_timeout_add(static_cast<guint>(std::atoi(env)), pick_probe, &c); c.pick_source = g_timeout_add(static_cast<guint>(std::atoi(env)), pick_probe, &c);
} }
// A first run gets the tour here, on this window, before a source is picked: the chooser is the
// surface a first run meets first, and the window's own auto-start is unreachable until this call
// has returned with a source. 1.5 s is the window's own delay and for its reason -- the window has
// to be up and laid out before anything points at it -- and the walk's own finish sets
// `tour_pending`, which is how it continues into the application's window.
if (!load_settings().tour_seen) {
c.tour_source = g_timeout_add(1500, chooser_tour_autostart, &c);
}
// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`: schedule the tour probe *after* the auto-start above, so the
// reading is of a tour the run itself began rather than one the probe started.
if (const char* env = g_getenv("UDPSCOPE_PROBE_CHOOSER_TOUR_MS")) {
c.tour_probe_source = g_timeout_add(static_cast<guint>(std::atoi(env)), chooser_tour_probe, &c);
}
c.loop = g_main_loop_new(nullptr, FALSE); c.loop = g_main_loop_new(nullptr, FALSE);
g_main_loop_run(c.loop); g_main_loop_run(c.loop);
+1 -1
View File
@@ -4926,7 +4926,7 @@ void on_activate(GtkApplication* application, gpointer user) {
GtkWidget* overlay = gtk_overlay_new(); GtkWidget* overlay = gtk_overlay_new();
gtk_window_set_child(GTK_WINDOW(app->window), overlay); gtk_window_set_child(GTK_WINDOW(app->window), overlay);
gtk_overlay_set_child(GTK_OVERLAY(overlay), side_view); gtk_overlay_set_child(GTK_OVERLAY(overlay), side_view);
app->tour_overlay = overlay; app->tour.overlay = overlay;
build_tour(*app); build_tour(*app);
} }
+10
View File
@@ -44,13 +44,23 @@
</child> </child>
</object> </object>
</child> </child>
<child>
<!-- The hint line, wrapped in a box of its own so the contextual help's "(i)" can join it: a
`GtkLabel` cannot host a sibling, and a hint overlaid in a corner could cover the text it
is meant to sit beside. The label keeps the slack, so the mark sits at the row's right. -->
<object class="GtkBox" id="signals_note">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<child> <child>
<object class="GtkLabel" id="signals_hint"> <object class="GtkLabel" id="signals_hint">
<property name="label" translatable="yes">drag a signal onto a plot</property> <property name="label" translatable="yes">drag a signal onto a plot</property>
<property name="halign">start</property> <property name="halign">start</property>
<property name="hexpand">1</property>
<property name="css-classes">dim-label</property> <property name="css-classes">dim-label</property>
</object> </object>
</child> </child>
</object>
</child>
<child> <child>
<object class="GtkListBox" id="signal_list"> <object class="GtkListBox" id="signal_list">
<property name="selection-mode">none</property> <property name="selection-mode">none</property>
+29 -1
View File
@@ -66,7 +66,33 @@
<property name="title" translatable="yes">Sources</property> <property name="title" translatable="yes">Sources</property>
<property name="icon-name">network-transmit-receive-symbolic</property> <property name="icon-name">network-transmit-receive-symbolic</property>
<property name="child"> <property name="child">
<object class="GtkBox">
<property name="orientation">vertical</property>
<child>
<!-- The heading row above the list. It exists mainly so the contextual help's
"(i)" has a row of its own to join: a hint appended to the list would be a
row among the sources, and one overlaid on the scroller could cover a row
it is meant to explain. The name is dim, because the switcher's pill above
already says which page this is. -->
<object class="GtkBox" id="sources_head">
<property name="orientation">horizontal</property>
<property name="spacing">6</property>
<property name="margin-start">12</property>
<property name="margin-end">6</property>
<property name="margin-top">6</property>
<child>
<object class="GtkLabel" id="sources_heading">
<property name="label" translatable="yes">Saved sources</property>
<property name="halign">start</property>
<property name="hexpand">1</property>
<property name="css-classes">dim-label</property>
</object>
</child>
</object>
</child>
<child>
<object class="GtkScrolledWindow"> <object class="GtkScrolledWindow">
<property name="vexpand">1</property>
<property name="hscrollbar-policy">never</property> <property name="hscrollbar-policy">never</property>
<property name="child"> <property name="child">
<object class="GtkListBox" id="sources_list"> <object class="GtkListBox" id="sources_list">
@@ -74,6 +100,8 @@
</object> </object>
</property> </property>
</object> </object>
</child>
</object>
</property> </property>
</object> </object>
</child> </child>
@@ -97,7 +125,7 @@
</object> </object>
</child> </child>
<child> <child>
<object class="GtkBox"> <object class="GtkBox" id="waves_actions">
<property name="orientation">horizontal</property> <property name="orientation">horizontal</property>
<property name="spacing">6</property> <property name="spacing">6</property>
<property name="halign">end</property> <property name="halign">end</property>
+89 -25
View File
@@ -57,6 +57,48 @@ enum class EventGroupKind {
Compare, ///< two events in two plots side by side, at one scale, with synchronised axes Compare, ///< two events in two plots side by side, at one scale, with synchronised axes
Accumulate, ///< up to four events overlaid in the same traces, translucent, so agreement is brightness Accumulate, ///< up to four events overlaid in the same traces, translucent, so agreement is brightness
}; };
// --- the guided tour, shared by both windows (ui_tour.cpp) -------------------
//
// The tour is *two* tours now -- the main window's and the source chooser's -- because the chooser is
// the window a first run meets first and the surfaces it must explain are its own. The machinery is
// one implementation: a `TourView` is the overlay children (the four dim rectangles, the spotlight
// outline and the card) plus where the walk stands in it, and either window holds one. What differs
// is only the *stops*: a stop's target is a widget the owner resolves, so each window keeps its own
// table (the chooser runs before `App` exists and could not name one of `App`'s fields).
/// One stop of a guided tour: the widget to spotlight, and the two paragraphs of text beside it.
/// The target is resolved when the tour starts, so the table may name widgets that do not exist yet.
struct TourStop {
GtkWidget* target;
const char* title;
const char* body;
};
/// A tour's widgets and its place in the walk. `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.
struct TourView {
GtkWidget* overlay = nullptr;
GtkWidget* dim[4] = {};
GtkWidget* spot = nullptr;
GtkWidget* card = nullptr;
GtkWidget* title = nullptr;
GtkWidget* body = nullptr;
GtkWidget* counter = nullptr;
GtkWidget* back = nullptr;
GtkWidget* next = nullptr;
int hole[4] = {0, 0, 0, 0};
bool running = false;
int index = 0;
std::vector<TourStop> plan;
/// Runs when the walk passes its last stop (Next/Finish, never Skip): the chooser's hands the tour
/// on to the application through `Config::tour_pending`.
void (*on_done)(void* user) = nullptr;
void* done_user = nullptr;
};
struct App { struct App {
ScopeModel* model = nullptr; ScopeModel* model = nullptr;
Acquisition* acq = nullptr; Acquisition* acq = nullptr;
@@ -232,26 +274,17 @@ struct App {
/// are not a panel with a heading. /// are not a panel with a heading.
GtkWidget* status_row = nullptr; GtkWidget* status_row = nullptr;
GtkWidget* window_control_slot = nullptr; GtkWidget* window_control_slot = nullptr;
/// The guided tour. `tour_overlay` is a `GtkOverlay` inserted above the window's content, and the /// The hint hosts that are *not* a pane's heading row: a specific control's own row, so the "?" sits
/// four dim rectangles, the spotlight and the card are its overlay children. `tour_plan` is the /// beside the thing it explains -- the "record events" switch, the search block's action row, and the
/// signals pane's hint line (whose box exists so a hint can join it beside the text).
GtkWidget* trigger_record_row = nullptr;
GtkWidget* trigger_pre_row = nullptr;
GtkWidget* signals_note = 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 /// 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 /// walked over. The machinery is shared with the source chooser's tour -- see `TourView`.
/// from the last frame that could measure the target: a frame can come between two layout passes, TourView tour;
/// where `gtk_widget_compute_bounds()` answers nothing, and a hole that emptied there would make
/// the spotlight and the card jump for that frame.
GtkWidget* tour_overlay = nullptr;
GtkWidget* tour_dim[4] = {};
GtkWidget* tour_spot = nullptr;
GtkWidget* tour_card = nullptr;
GtkWidget* tour_title = nullptr;
GtkWidget* tour_body = nullptr;
GtkWidget* tour_counter = nullptr;
GtkWidget* tour_back = nullptr;
GtkWidget* tour_next = nullptr;
int tour_hole[4] = {0, 0, 0, 0};
bool tour_running = false;
int tour_index = 0;
std::vector<int> tour_plan;
/// The first-run auto-start: set by the timer that fires it, and read by the probe and the dump. /// The 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". /// The decision itself is `settings.tour_seen`, which is the whole of "this has happened before".
bool tour_auto_started = false; bool tour_auto_started = false;
@@ -1732,19 +1765,50 @@ 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 /// 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). /// 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); void context_help_add(App& app, const char* id, GtkWidget* host);
/// The same affordance for a surface that is *not* in `App`'s table -- the source chooser's, which
/// runs before `App` exists and so cannot name one of its fields. `host` is a `GtkBox` and the button
/// joins it as its last child, which is the rule the table's own rows follow; `on` false takes any
/// hint off `host` instead. `shortcut` may be "".
void context_help_button(GtkWidget* host, const char* id, const char* title, const char* body,
const char* shortcut, bool on);
/// How many hints a widget subtree carries, and their ids, for a window that has no `App` (the
/// chooser's probe). `context_help_count()` is this with `App::window` as the root.
int context_help_count_in(GtkWidget* root, std::string* ids);
/// The size of the first *visible* hint button in a subtree, read the way the probe's geometry is
/// (`gtk_widget_compute_bounds()`), and the number a shrinking affordance is checked against. Both
/// are 0 when the layer is off or nothing on screen carries one.
void context_help_size_in(GtkWidget* root, int* width, int* height);
/// `context_help_size_in()` with `App::window` as the root.
void context_help_size(App& app, int* width, int* height);
/// Builds the tour's overlay children (the four dim rectangles, the spotlight outline and the card) /// Builds a tour's overlay children (the four dim rectangles, the spotlight outline and the card)
/// inside `app.tour_overlay`. Called once, as the overlay is inserted. /// inside `overlay`. Called once, as the overlay is inserted, by either window that draws a tour.
void tour_build(TourView& view, GtkWidget* overlay);
/// Starts at the first stop. `stops` is the walk, already filtered to what is on screen; `on_done`
/// (may be null) runs when the walk passes its last stop, and is how the chooser hands off.
void tour_begin(TourView& view, std::vector<TourStop> stops, void (*on_done)(void* user),
void* done_user);
/// Moves `delta` stops, finishing the walk (and calling `on_done`) on the way past the last one.
void tour_advance(TourView& view, int delta);
/// Ends the walk without finishing it: every child of the overlay is hidden and nothing is left on
/// screen. Skip and Escape.
void tour_end(TourView& view);
/// Called from the tick while the walk 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 tour_update(TourView& view);
/// The widget the current stop points at, or nullptr when the walk is not on a stop.
GtkWidget* tour_target(TourView& view);
/// Builds the *window's* tour inside `app.tour.overlay`. Called once, as the overlay is inserted.
void build_tour(App& app); void build_tour(App& app);
/// Starts the tour at its first step and writes `tour_seen`. Safe to call while it already runs -- /// Starts the window's tour at its first step and writes `tour_seen`. Safe to call while it already
/// it restarts at step 0. /// runs -- it restarts at step 0.
void start_tour(App& app); void start_tour(App& app);
/// Moves `delta` steps, ending the tour on the way past the last one. /// Moves `delta` steps, ending the tour on the way past the last one.
void tour_step(App& app, int delta); 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. /// Ends the tour: every child of the overlay is hidden and nothing of the tour is left on screen.
void tour_dismiss(App& app); void tour_dismiss(App& app);
/// Called from the tick while the tour runs: re-measures the target each frame, so a window resize /// The window's tour, re-measured each frame.
/// keeps the spotlight on the widget, and places the card where it does not cover it.
void update_tour(App& app); void update_tour(App& app);
bool tour_active(const 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 /// The widget the current tour step points at, or nullptr when the tour is not on a step. The probe
+102 -20
View File
@@ -1,24 +1,30 @@
// SPDX-License-Identifier: MIT // SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2026 Martino Ferrari // SPDX-FileCopyrightText: 2026 Martino Ferrari
// //
// The contextual-help layer: one small "?" beside each surface the application can explain, whose // The contextual-help layer: one small "(i)" 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 // bubble says what that surface is for, in a title, a sentence or two, and the key that reaches it
// when there is one. // when there is one.
// //
// The surfaces are the table below -- a surface is a row -- and a hint *joins the surface's own // 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, // row* rather than being overlaid on it: a sibling of the title, the switch or the pencil can never
// which an overlay child in a corner can and does. The one exception is the plot, which has no // cover a control, which an overlay child in a corner can and does. Most rows name a panel's heading,
// heading: its "?" is an overlay child of the overlay the layout editor already uses, so it costs // which is where its switch and its pencil live; the rest name the row of a *specific* control (the
// nothing and sits over the plot's top-right corner. // "record events" switch, the search block) so a hint can sit beside the thing it explains. The one
// exception is the plot, which has no heading: its "(i)" 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 // 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 // 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. // heading dies with its builder on every reopen, and a list of pointers into it would go stale.
//
// The same affordance serves a window that has no `App` -- the source chooser, which runs before one
// exists -- through `context_help_button()`, so both windows draw *one* button.
#include "ui_app.hpp" #include "ui_app.hpp"
#include <gtk/gtk.h> #include <gtk/gtk.h>
#include <cmath>
#include <cstdio> #include <cstdio>
#include <cstring> #include <cstring>
#include <string> #include <string>
@@ -27,11 +33,14 @@ namespace udpscope {
namespace ui_impl { namespace ui_impl {
namespace { namespace {
/// The "?" glyph. `help-about-symbolic` is already the status bar's information button, so a second /// The "(i)" glyph. Adwaita's `dialog-information-symbolic` is the plain information mark -- a circle
/// thing meaning "help" needs a name of its own: this is Adwaita's question mark, and the dump reads /// with a lower-case i -- and it is the one this layer means; `help-about-symbolic` is already the
/// it back through `gtk_icon_theme_has_icon()` -- a hint button whose icon does not resolve is a /// status bar's information button, so a second thing meaning "help" needed a name of its own.
/// blank control, the failure the pane [edit] buttons already paid for once. /// `dialog-question-symbolic` was the first choice and read as "what is this?" rather than as the
constexpr const char* kHintIcon = "dialog-question-symbolic"; /// neutral information mark the ask names. The dump reads the name 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-information-symbolic";
/// The data key on a hint button, carrying the surface's id as the value. One key is both the mark /// 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. /// that says "this is a hint" and the name the probe and the dump report it by.
constexpr const char* kHintKey = "udpscope-help-hint"; constexpr const char* kHintKey = "udpscope-help-hint";
@@ -89,6 +98,22 @@ constexpr HelpHint kHelpHints[] = {
"Every triggered acquisition the cache keeps, with a picture of each. Open one to read it as a " "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.", "file, or select two or four and have them compared in two plots or overlaid in one.",
"", &App::events_head}, "", &App::events_head},
{"events_record", "Recording events",
"With this on, every acquisition the trigger latches is written to the event cache -- the "
"samples around it and a picture of it -- so an event can be read again long after it has left "
"the live buffer. It is the switch beside it, and the same one the events pane shows the result "
"of.",
"", &App::trigger_record_row},
{"trigger_advanced", "The trigger's finer settings",
"Pre-trigger share is how much of the event window is kept *before* the crossing, and hold-off "
"is the quiet time after a latch in which nothing new can be armed -- the two that decide what a "
"capture looks like. The condition rows above appear and disappear with the condition chosen.",
"", &App::trigger_pre_row},
{"signals_config", "Configuring signals",
"The list is locked until the pencil above opens the per-signal controls: the calibration that "
"maps raw counts to a unit, the colour a trace is drawn in, and which plot a signal is dragged "
"onto. A signal nothing measures can be switched off to stop acquiring it.",
"", &App::signals_note},
{"window", "The time window", {"window", "The time window",
"How much time is on screen, counted back from the newest sample. Type a span, change its unit, " "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 " "or step by decades; the trigger's event window is framed out of the same span. No gesture on "
@@ -106,7 +131,7 @@ namespace {
/// The bubble: a bold title, the paragraph, and the key when there is one. Wrapped at a stated /// 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. /// 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* hint_popover(const char* title_text, const char* body_text, const char* shortcut) {
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_margin_start(box, 12); gtk_widget_set_margin_start(box, 12);
gtk_widget_set_margin_end(box, 12); gtk_widget_set_margin_end(box, 12);
@@ -115,19 +140,19 @@ GtkWidget* hint_popover(const HelpHint& hint) {
GtkWidget* title = gtk_label_new(nullptr); GtkWidget* title = gtk_label_new(nullptr);
char markup[128]; char markup[128];
std::snprintf(markup, sizeof(markup), "<b>%s</b>", hint.title); std::snprintf(markup, sizeof(markup), "<b>%s</b>", title_text);
gtk_label_set_markup(GTK_LABEL(title), markup); gtk_label_set_markup(GTK_LABEL(title), markup);
gtk_label_set_xalign(GTK_LABEL(title), 0.0f); gtk_label_set_xalign(GTK_LABEL(title), 0.0f);
gtk_box_append(GTK_BOX(box), title); gtk_box_append(GTK_BOX(box), title);
GtkWidget* body = gtk_label_new(hint.body); GtkWidget* body = gtk_label_new(body_text);
gtk_label_set_wrap(GTK_LABEL(body), TRUE); gtk_label_set_wrap(GTK_LABEL(body), TRUE);
gtk_label_set_max_width_chars(GTK_LABEL(body), 42); gtk_label_set_max_width_chars(GTK_LABEL(body), 42);
gtk_label_set_xalign(GTK_LABEL(body), 0.0f); gtk_label_set_xalign(GTK_LABEL(body), 0.0f);
gtk_box_append(GTK_BOX(box), body); gtk_box_append(GTK_BOX(box), body);
if (hint.shortcut != nullptr && hint.shortcut[0] != '\0') { if (shortcut != nullptr && shortcut[0] != '\0') {
const std::string key = std::string("Shortcut: ") + hint.shortcut; const std::string key = std::string("Shortcut: ") + shortcut;
GtkWidget* line = gtk_label_new(key.c_str()); GtkWidget* line = gtk_label_new(key.c_str());
gtk_widget_add_css_class(line, "dim-label"); gtk_widget_add_css_class(line, "dim-label");
gtk_label_set_xalign(GTK_LABEL(line), 0.0f); gtk_label_set_xalign(GTK_LABEL(line), 0.0f);
@@ -139,15 +164,19 @@ GtkWidget* hint_popover(const HelpHint& hint) {
return popover; return popover;
} }
/// One "?" button, marked with its surface's id. /// One "(i)" button, marked with its surface's id. `udpscope-hint` is what makes it *tiny*: the
GtkWidget* hint_button(const HelpHint& hint) { /// default menu button is a 34 px control with 8 px of padding, which is the size that read as a
/// second toolbar button beside each heading. The class trims the box and the glyph together (see
/// `install_css()`), and `flat` keeps Adwaita from drawing it as a button until it is hovered.
GtkWidget* hint_button(const char* id, const char* title, const char* body, const char* shortcut) {
GtkWidget* button = gtk_menu_button_new(); GtkWidget* button = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(button), kHintIcon); gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(button), kHintIcon);
gtk_menu_button_set_popover(GTK_MENU_BUTTON(button), hint_popover(hint)); gtk_menu_button_set_popover(GTK_MENU_BUTTON(button), hint_popover(title, body, shortcut));
gtk_widget_add_css_class(button, "flat"); gtk_widget_add_css_class(button, "flat");
gtk_widget_add_css_class(button, "udpscope-hint");
gtk_widget_set_valign(button, GTK_ALIGN_CENTER); gtk_widget_set_valign(button, GTK_ALIGN_CENTER);
gtk_widget_set_tooltip_text(button, "What is this?"); gtk_widget_set_tooltip_text(button, "What is this?");
g_object_set_data_full(G_OBJECT(button), kHintKey, g_strdup(hint.id), g_free); g_object_set_data_full(G_OBJECT(button), kHintKey, g_strdup(id), g_free);
return button; return button;
} }
@@ -186,7 +215,7 @@ void sync_one(App& app, int row, GtkWidget* host) {
const HelpHint& hint = kHelpHints[row]; const HelpHint& hint = kHelpHints[row];
remove_hint(host); remove_hint(host);
if (!app.settings.contextual_help) return; if (!app.settings.contextual_help) return;
GtkWidget* button = hint_button(hint); GtkWidget* button = hint_button(hint.id, hint.title, hint.body, hint.shortcut);
if (GTK_IS_BOX(host)) { if (GTK_IS_BOX(host)) {
gtk_box_append(GTK_BOX(host), button); gtk_box_append(GTK_BOX(host), button);
} else if (GTK_IS_OVERLAY(host)) { } else if (GTK_IS_OVERLAY(host)) {
@@ -220,6 +249,23 @@ int count_hints(GtkWidget* widget, std::string* ids) {
return n; return n;
} }
/// The first *visible* hint button in the subtree, which is the one whose allocation
/// `context_help_size_in()` reports -- every hint shares the one `udpscope-hint` size, so one of them
/// is the reading. Invisible ones are skipped: a hint inside a collapsed expander (the trigger's
/// condition rows live in one) is off screen and reads 0x0, which would make the number say the
/// affordance had shrunk to nothing.
GtkWidget* first_hint(GtkWidget* widget) {
if (widget == nullptr) return nullptr;
if (g_object_get_data(G_OBJECT(widget), kHintKey) != nullptr) {
return gtk_widget_is_visible(widget) ? widget : nullptr;
}
for (GtkWidget* child = gtk_widget_get_first_child(widget); child != nullptr;
child = gtk_widget_get_next_sibling(child)) {
if (GtkWidget* found = first_hint(child)) return found;
}
return nullptr;
}
} // namespace } // namespace
void context_help_sync(App& app) { void context_help_sync(App& app) {
@@ -232,12 +278,48 @@ void context_help_add(App& app, const char* id, GtkWidget* host) {
sync_one(app, static_cast<int>(hint - kHelpHints), host); sync_one(app, static_cast<int>(hint - kHelpHints), host);
} }
void context_help_button(GtkWidget* host, const char* id, const char* title, const char* body,
const char* shortcut, bool on) {
if (host == nullptr || !GTK_IS_BOX(host)) return;
remove_hint(host);
if (!on) return;
gtk_box_append(GTK_BOX(host), hint_button(id, title, body, shortcut));
}
int context_help_count_in(GtkWidget* root, std::string* ids) {
if (ids != nullptr) ids->clear();
return count_hints(root, ids);
}
int context_help_count(App& app, std::string* ids) { int context_help_count(App& app, std::string* ids) {
if (ids != nullptr) ids->clear(); if (ids != nullptr) ids->clear();
if (app.window == nullptr) return 0; if (app.window == nullptr) return 0;
return count_hints(app.window, ids); return count_hints(app.window, ids);
} }
void context_help_size_in(GtkWidget* root, int* width, int* height) {
if (width != nullptr) *width = 0;
if (height != nullptr) *height = 0;
GtkWidget* hint = first_hint(root);
if (hint == nullptr) return;
graphene_rect_t r = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
// `gtk_widget_compute_bounds()` -- the rectangle on screen -- and not `get_width()`: the latter is
// the widget's *content* box, a border and a padding short of what a reader sees, which is the
// measurement trap the tour card already paid for (see invariants-gtk-and-chrome.md).
if (!gtk_widget_compute_bounds(hint, root, &r)) return;
if (width != nullptr) *width = static_cast<int>(std::lround(r.size.width));
if (height != nullptr) *height = static_cast<int>(std::lround(r.size.height));
}
void context_help_size(App& app, int* width, int* height) {
if (app.window == nullptr) {
if (width != nullptr) *width = 0;
if (height != nullptr) *height = 0;
return;
}
context_help_size_in(app.window, width, height);
}
void on_contextual_help_toggled(GtkToggleButton* button, gpointer user) { void on_contextual_help_toggled(GtkToggleButton* button, gpointer user) {
App* app = static_cast<App*>(user); App* app = static_cast<App*>(user);
const bool on = gtk_toggle_button_get_active(button) != FALSE; const bool on = gtk_toggle_button_get_active(button) != FALSE;
+10 -6
View File
@@ -4565,11 +4565,11 @@ void helpui_report_geometry(App& a, const char* what) {
graphene_rect_t card = GRAPHENE_RECT_INIT(0, 0, 0, 0); graphene_rect_t card = GRAPHENE_RECT_INIT(0, 0, 0, 0);
GtkWidget* t = tour_target(a); GtkWidget* t = tour_target(a);
const bool have_spot = const bool have_spot =
a.tour_spot != nullptr && gtk_widget_compute_bounds(a.tour_spot, a.window, &spot); a.tour.spot != nullptr && gtk_widget_compute_bounds(a.tour.spot, a.window, &spot);
const bool have_target = t != nullptr && gtk_widget_is_visible(t) && const bool have_target = t != nullptr && gtk_widget_is_visible(t) &&
gtk_widget_compute_bounds(t, a.window, &target); gtk_widget_compute_bounds(t, a.window, &target);
const bool have_card = const bool have_card =
a.tour_card != nullptr && gtk_widget_compute_bounds(a.tour_card, a.window, &card); a.tour.card != nullptr && gtk_widget_compute_bounds(a.tour.card, a.window, &card);
const auto contains = [](const graphene_rect_t& outer, const graphene_rect_t& inner) { 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 && 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.x + outer.size.width >= inner.origin.x + inner.size.width - 0.5 &&
@@ -4607,7 +4607,11 @@ gboolean helpui_probe_step(gpointer user) {
if (tour_active(*a)) tour_dismiss(*a); if (tour_active(*a)) tour_dismiss(*a);
std::string ids; std::string ids;
const int hints = context_help_count(*a, &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 hint_w = 0;
int hint_h = 0;
context_help_size(*a, &hint_w, &hint_h);
std::fprintf(stderr, "[helpui] contextual on: hints=%d ids={%s} hint=%dx%d\n", hints,
ids.c_str(), hint_w, hint_h);
int total = 0; int total = 0;
int clicks = 0; int clicks = 0;
helpui_count_controllers(a, &total, &clicks); helpui_count_controllers(a, &total, &clicks);
@@ -4736,11 +4740,11 @@ gboolean helpui_probe_step(gpointer user) {
"[helpui] after Skip: visible=%d dims_hidden=%d spot_hidden=%d card_hidden=%d " "[helpui] after Skip: visible=%d dims_hidden=%d spot_hidden=%d card_hidden=%d "
"controllers=%d (was %d) click_gestures=%d\n", "controllers=%d (was %d) click_gestures=%d\n",
tour_active(*a) ? 1 : 0, tour_active(*a) ? 1 : 0,
hidden(a->tour_dim[0]) && hidden(a->tour_dim[1]) && hidden(a->tour_dim[2]) && hidden(a->tour.dim[0]) && hidden(a->tour.dim[1]) && hidden(a->tour.dim[2]) &&
hidden(a->tour_dim[3]) hidden(a->tour.dim[3])
? 1 ? 1
: 0, : 0,
hidden(a->tour_spot) ? 1 : 0, hidden(a->tour_card) ? 1 : 0, total, hidden(a->tour.spot) ? 1 : 0, hidden(a->tour.card) ? 1 : 0, total,
a->dbg_helpui_controllers, clicks); a->dbg_helpui_controllers, clicks);
capture_help_state(*a); capture_help_state(*a);
std::fprintf(stderr, "[helpui] layer: %s\n", a->dbg_context_help.c_str()); std::fprintf(stderr, "[helpui] layer: %s\n", a->dbg_context_help.c_str());
+3
View File
@@ -594,6 +594,9 @@ GtkWidget* build_signal_panel(App& app) {
app.signal_list = GTK_WIDGET(gtk_builder_get_object(ui, "signal_list")); 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`). // 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_head = GTK_WIDGET(gtk_builder_get_object(ui, "signals_head"));
// The hint line under it, which is where the "signals_config" hint joins: the heading already
// carries one, and the rule the layer follows is a hint beside the thing it explains.
app.signals_note = GTK_WIDGET(gtk_builder_get_object(ui, "signals_note"));
app.signals_edit = GTK_WIDGET(gtk_builder_get_object(ui, "signals_edit")); app.signals_edit = GTK_WIDGET(gtk_builder_get_object(ui, "signals_edit"));
if (app.signals_edit != nullptr) { if (app.signals_edit != nullptr) {
g_signal_connect(app.signals_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app); g_signal_connect(app.signals_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app);
+237 -184
View File
@@ -2,7 +2,7 @@
// SPDX-FileCopyrightText: 2026 Martino Ferrari // 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 // 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. // `GtkOverlay` inserted above a window's content.
// //
// Why an overlay and not a popover per step: a popover takes a pointer grab, and a run of them takes // 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 // one per step. The overlay takes none at all -- what makes the tour modal is the four dim
@@ -15,6 +15,13 @@
// is re-measured on every frame -- the target's own allocation is 0 on the frame it is first shown // 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 // (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. // resize moves it under a spotlight that a one-shot measurement would leave behind.
//
// There are *two* tours and one implementation. The main window's walks the plot, the side panel,
// the trigger and the like; the source chooser's -- which appears *before* `App` exists -- walks the
// chooser's own widgets and then hands the tour on to the window through `Config::tour_pending`. The
// machinery below is written against a `TourView` and a list of `TourStop`s, so the difference
// between the two is only which widgets they name: the chooser keeps its table in
// `source_dialog.cpp`, where its widgets live.
#include "ui_app.hpp" #include "ui_app.hpp"
@@ -24,44 +31,13 @@
#include <cmath> #include <cmath>
#include <cstdio> #include <cstdio>
#include <string> #include <string>
#include <utility>
#include <vector>
namespace udpscope { namespace udpscope {
namespace ui_impl { namespace ui_impl {
namespace { namespace {
/// One step. The target is measured, never named by a stored rectangle: the point of re-measuring
/// every frame is that nothing here knows where the widget is.
struct TourStep {
const char* id;
const char* title;
const char* body;
GtkWidget* App::* target;
};
constexpr TourStep kTourSteps[] = {
{"plot", "The plot",
"Every signal you have switched on is drawn here, all rows sharing one time axis. The wheel "
"zooms, a drag pans, and a right-click on a row opens that plot's own menu.",
&App::area_overlay},
{"side", "The side panel",
"The working surface beside the plot. Measure holds the cursors and the measurements; Signals "
"holds the trigger and the list of signals. The buttons in the bar above show and hide it.",
&App::side_box},
{"trigger", "The trigger",
"Describe an event -- a signal, a condition, a level and the window around it -- and arm it: "
"the view then freezes on the first event that matches, however rare it is.",
&App::trigger_body},
{"signals", "The signals",
"Every signal the source declares, each with the switch that acquires it. A signal has to be "
"switched on before it can be plotted, measured or searched.",
&App::signal_list},
{"status", "The status line",
"The stream's own report, once a second: the display mode, the rate arriving, the window on "
"screen and the buffer holding it. The information button opens the counters behind it.",
&App::status_row},
};
constexpr int kTourStepCount = static_cast<int>(sizeof(kTourSteps) / sizeof(kTourSteps[0]));
/// The card's width. Wide enough for a comfortable line of the paragraph, narrow enough to fit /// 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 /// 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 /// that is a card that has to cover its own target. It is a *floor* on the box, not a cap on the
@@ -94,173 +70,166 @@ void place_natural(GtkWidget* widget, int x, int y) {
gtk_widget_set_margin_top(widget, std::max(0, y)); gtk_widget_set_margin_top(widget, std::max(0, y));
} }
/// The step currently walked, or nullptr when the tour is not on a step. /// The stop currently walked, or nullptr when the walk is not on a stop.
const TourStep* current_step(const App& app) { const TourStop* current_stop(const TourView& view) {
if (app.tour_index < 0 || app.tour_index >= static_cast<int>(app.tour_plan.size())) return nullptr; if (view.index < 0 || view.index >= static_cast<int>(view.plan.size())) return nullptr;
return &kTourSteps[app.tour_plan[static_cast<std::size_t>(app.tour_index)]]; return &view.plan[static_cast<std::size_t>(view.index)];
} }
/// Restates the card's text for the current step. Called on a change of step, not per frame. /// Restates the card's text for the current stop. Called on a change of step, not per frame.
void show_step(App& app) { void show_step(TourView& view) {
const TourStep* step = current_step(app); const TourStop* stop = current_stop(view);
if (step == nullptr || app.tour_card == nullptr) return; if (stop == nullptr || view.card == nullptr) return;
// The hole belongs to the step: reusing the last one for a frame would put the spotlight on the // The hole belongs to the step: reusing the last one for a frame would put the spotlight on the
// surface the tour has just left, and the next frame fills this one in. // surface the tour has just left, and the next frame fills this one in.
app.tour_hole[0] = app.tour_hole[1] = app.tour_hole[2] = app.tour_hole[3] = 0; view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0;
char markup[128]; char markup[128];
std::snprintf(markup, sizeof(markup), "<b>%s</b>", step->title); std::snprintf(markup, sizeof(markup), "<b>%s</b>", stop->title);
gtk_label_set_markup(GTK_LABEL(app.tour_title), markup); gtk_label_set_markup(GTK_LABEL(view.title), markup);
gtk_label_set_text(GTK_LABEL(app.tour_body), step->body); gtk_label_set_text(GTK_LABEL(view.body), stop->body);
char counter[64]; char counter[64];
std::snprintf(counter, sizeof(counter), "%d of %d", std::snprintf(counter, sizeof(counter), "%d of %d", view.index + 1,
app.tour_index + 1, static_cast<int>(app.tour_plan.size())); static_cast<int>(view.plan.size()));
gtk_label_set_text(GTK_LABEL(app.tour_counter), counter); gtk_label_set_text(GTK_LABEL(view.counter), counter);
gtk_widget_set_visible(app.tour_back, app.tour_index > 0); gtk_widget_set_visible(view.back, view.index > 0);
gtk_button_set_label(GTK_BUTTON(app.tour_next), gtk_button_set_label(GTK_BUTTON(view.next),
app.tour_index + 1 >= static_cast<int>(app.tour_plan.size()) ? "Finish" view.index + 1 >= static_cast<int>(view.plan.size()) ? "Finish" : "Next");
: "Next");
} }
} // namespace } // namespace
GtkWidget* tour_target(App& app) { // --- the machinery, shared by both windows ----------------------------------
const TourStep* step = current_step(app);
return step != nullptr ? app.*(step->target) : nullptr;
}
void build_tour(App& app) { void tour_build(TourView& view, GtkWidget* overlay) {
if (app.tour_overlay == nullptr) return; if (overlay == nullptr || view.card != nullptr) return;
for (GtkWidget*& dim : app.tour_dim) { view.overlay = overlay;
for (GtkWidget*& dim : view.dim) {
dim = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); dim = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(dim, "udpscope-tour-dim"); gtk_widget_add_css_class(dim, "udpscope-tour-dim");
gtk_overlay_add_overlay(GTK_OVERLAY(app.tour_overlay), dim); gtk_overlay_add_overlay(GTK_OVERLAY(overlay), dim);
} }
app.tour_spot = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); view.spot = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(app.tour_spot, "udpscope-tour-spot"); gtk_widget_add_css_class(view.spot, "udpscope-tour-spot");
// The outline is a frame, not a shield: a click on the *target* is a click on the target. // 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_widget_set_can_target(view.spot, FALSE);
gtk_overlay_add_overlay(GTK_OVERLAY(app.tour_overlay), app.tour_spot); gtk_overlay_add_overlay(GTK_OVERLAY(overlay), view.spot);
// The card, last: overlay children are painted in the order they were added, and the card has to // 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. // be above the dim strips that cover the rest of the window.
app.tour_card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8); view.card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_add_css_class(app.tour_card, "udpscope-tour-card"); gtk_widget_add_css_class(view.card, "udpscope-tour-card");
gtk_widget_set_size_request(app.tour_card, kCardWidth, -1); gtk_widget_set_size_request(view.card, kCardWidth, -1);
app.tour_title = gtk_label_new(nullptr); view.title = gtk_label_new(nullptr);
gtk_label_set_xalign(GTK_LABEL(app.tour_title), 0.0f); gtk_label_set_xalign(GTK_LABEL(view.title), 0.0f);
gtk_box_append(GTK_BOX(app.tour_card), app.tour_title); gtk_box_append(GTK_BOX(view.card), view.title);
app.tour_body = gtk_label_new(nullptr); view.body = gtk_label_new(nullptr);
gtk_label_set_wrap(GTK_LABEL(app.tour_body), TRUE); gtk_label_set_wrap(GTK_LABEL(view.body), TRUE);
// The cap that keeps the card narrow. `max_width_chars` is what bounds a wrapped label's *natural* // 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 // 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. // is that wide however it is placed.
gtk_label_set_max_width_chars(GTK_LABEL(app.tour_body), 30); gtk_label_set_max_width_chars(GTK_LABEL(view.body), 30);
gtk_label_set_xalign(GTK_LABEL(app.tour_body), 0.0f); gtk_label_set_xalign(GTK_LABEL(view.body), 0.0f);
gtk_box_append(GTK_BOX(app.tour_card), app.tour_body); gtk_box_append(GTK_BOX(view.card), view.body);
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
app.tour_counter = gtk_label_new(nullptr); view.counter = gtk_label_new(nullptr);
gtk_widget_add_css_class(app.tour_counter, "dim-label"); gtk_widget_add_css_class(view.counter, "dim-label");
gtk_label_set_xalign(GTK_LABEL(app.tour_counter), 0.0f); gtk_label_set_xalign(GTK_LABEL(view.counter), 0.0f);
gtk_widget_set_hexpand(app.tour_counter, TRUE); gtk_widget_set_hexpand(view.counter, TRUE);
gtk_box_append(GTK_BOX(footer), app.tour_counter); gtk_box_append(GTK_BOX(footer), view.counter);
app.tour_back = gtk_button_new_with_label("Back"); view.back = gtk_button_new_with_label("Back");
g_signal_connect(app.tour_back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { g_signal_connect(view.back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
tour_step(*static_cast<App*>(user), -1); tour_advance(*static_cast<TourView*>(user), -1);
}), }),
&app); &view);
gtk_box_append(GTK_BOX(footer), app.tour_back); gtk_box_append(GTK_BOX(footer), view.back);
GtkWidget* skip = gtk_button_new_with_label("Skip"); GtkWidget* skip = gtk_button_new_with_label("Skip");
g_signal_connect(skip, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { g_signal_connect(skip, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
tour_dismiss(*static_cast<App*>(user)); tour_end(*static_cast<TourView*>(user));
}), }),
&app); &view);
gtk_box_append(GTK_BOX(footer), skip); gtk_box_append(GTK_BOX(footer), skip);
app.tour_next = gtk_button_new_with_label("Next"); view.next = gtk_button_new_with_label("Next");
gtk_widget_add_css_class(app.tour_next, "suggested-action"); gtk_widget_add_css_class(view.next, "suggested-action");
g_signal_connect(app.tour_next, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) { g_signal_connect(view.next, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
tour_step(*static_cast<App*>(user), 1); tour_advance(*static_cast<TourView*>(user), 1);
}), }),
&app); &view);
gtk_box_append(GTK_BOX(footer), app.tour_next); gtk_box_append(GTK_BOX(footer), view.next);
gtk_box_append(GTK_BOX(app.tour_card), footer); gtk_box_append(GTK_BOX(view.card), footer);
gtk_overlay_add_overlay(GTK_OVERLAY(app.tour_overlay), app.tour_card); gtk_overlay_add_overlay(GTK_OVERLAY(overlay), view.card);
// Everything starts hidden: the tour is not on screen until it is started. // 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); for (GtkWidget* dim : view.dim) gtk_widget_set_visible(dim, FALSE);
gtk_widget_set_visible(app.tour_spot, FALSE); gtk_widget_set_visible(view.spot, FALSE);
gtk_widget_set_visible(app.tour_card, FALSE); gtk_widget_set_visible(view.card, FALSE);
} }
void start_tour(App& app) { void tour_begin(TourView& view, std::vector<TourStop> stops, void (*on_done)(void* user),
if (app.tour_overlay == nullptr || app.tour_card == nullptr) return; void* done_user) {
// "The tour has run" is written when it *starts*: a user who skips it has still seen it, and the if (view.overlay == nullptr || view.card == nullptr) return;
// alternative -- marking it on the last step -- would offer the whole tour again to everyone who view.plan = std::move(stops);
// decided in the first second that they did not want it. if (view.plan.empty()) return;
if (!app.settings.tour_seen) { view.on_done = on_done;
app.settings.tour_seen = true; view.done_user = done_user;
(void)save_settings(app.settings); view.index = 0;
} view.running = true;
// The steps whose target is on screen, in table order. A surface that is not there is not walked for (GtkWidget* dim : view.dim) gtk_widget_set_visible(dim, TRUE);
// over -- the point of asking `is_visible()` rather than `get_visible()` is that a widget under a gtk_widget_set_visible(view.spot, TRUE);
// hidden parent still reports its own flag as true. gtk_widget_set_visible(view.card, TRUE);
app.tour_plan.clear(); show_step(view);
for (int i = 0; i < kTourStepCount; ++i) { tour_update(view);
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) { void tour_advance(TourView& view, int delta) {
if (!app.tour_running) return; if (!view.running) return;
const int next = app.tour_index + delta; const int next = view.index + delta;
if (next < 0 || next >= static_cast<int>(app.tour_plan.size())) { if (next < 0 || next >= static_cast<int>(view.plan.size())) {
tour_dismiss(app); // Past the last stop: this is the walk *finishing*, which is the one ending that hands the tour
// on. Take the callback out before `tour_end()` clears it.
void (*done)(void*) = view.on_done;
void* user = view.done_user;
tour_end(view);
if (done != nullptr) done(user);
return; return;
} }
app.tour_index = next; view.index = next;
show_step(app); show_step(view);
update_tour(app); tour_update(view);
} }
void tour_dismiss(App& app) { void tour_end(TourView& view) {
app.tour_running = false; view.running = false;
app.tour_index = 0; view.index = 0;
for (GtkWidget* dim : app.tour_dim) { view.plan.clear();
view.on_done = nullptr;
view.done_user = nullptr;
for (GtkWidget* dim : view.dim) {
if (dim != nullptr) gtk_widget_set_visible(dim, FALSE); if (dim != nullptr) gtk_widget_set_visible(dim, FALSE);
} }
if (app.tour_spot != nullptr) gtk_widget_set_visible(app.tour_spot, FALSE); if (view.spot != nullptr) gtk_widget_set_visible(view.spot, FALSE);
if (app.tour_card != nullptr) gtk_widget_set_visible(app.tour_card, FALSE); if (view.card != nullptr) gtk_widget_set_visible(view.card, FALSE);
} }
bool tour_active(const App& app) { return app.tour_running; } GtkWidget* tour_target(TourView& view) {
const TourStop* stop = current_stop(view);
return stop != nullptr ? stop->target : nullptr;
}
int tour_step_count(const App& app) { return static_cast<int>(app.tour_plan.size()); } void tour_update(TourView& view) {
if (!view.running || view.overlay == nullptr) return;
int tour_step_index(const App& app) { return app.tour_index; } const int w = gtk_widget_get_width(view.overlay);
const int h = gtk_widget_get_height(view.overlay);
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 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 // 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. // one-shot measurement would leave the hole in the corner.
GtkWidget* target = tour_target(app); GtkWidget* target = tour_target(view);
graphene_rect_t bounds = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f); graphene_rect_t bounds = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
const bool target_present = target != nullptr && gtk_widget_is_visible(target); const bool target_present = target != nullptr && gtk_widget_is_visible(target);
if (target_present && gtk_widget_compute_bounds(target, app.tour_overlay, &bounds)) { if (target_present && gtk_widget_compute_bounds(target, view.overlay, &bounds)) {
// The hole: the target, grown by three pixels so the outline sits *around* the widget rather than // The hole: the target, grown by three pixels so the outline sits *around* the widget rather than
// on it, and clamped to the overlay -- a target partly off screen is an ordinary case (a sidebar // on it, and clamped to the overlay -- a target partly off screen is an ordinary case (a sidebar
// dragged wide), and a negative rectangle is not something a margin can express. // dragged wide), and a negative rectangle is not something a margin can express.
@@ -268,28 +237,28 @@ void update_tour(App& app) {
const int y0 = static_cast<int>(std::floor(bounds.origin.y)) - 3; const int y0 = static_cast<int>(std::floor(bounds.origin.y)) - 3;
const int x1 = static_cast<int>(std::ceil(bounds.origin.x + bounds.size.width)) + 3; const int x1 = static_cast<int>(std::ceil(bounds.origin.x + bounds.size.width)) + 3;
const int y1 = static_cast<int>(std::ceil(bounds.origin.y + bounds.size.height)) + 3; const int y1 = static_cast<int>(std::ceil(bounds.origin.y + bounds.size.height)) + 3;
app.tour_hole[0] = std::clamp(x0, 0, w); view.hole[0] = std::clamp(x0, 0, w);
app.tour_hole[1] = std::clamp(y0, 0, h); view.hole[1] = std::clamp(y0, 0, h);
app.tour_hole[2] = std::clamp(x1, 0, w) - app.tour_hole[0]; view.hole[2] = std::clamp(x1, 0, w) - view.hole[0];
app.tour_hole[3] = std::clamp(y1, 0, h) - app.tour_hole[1]; view.hole[3] = std::clamp(y1, 0, h) - view.hole[1];
} else if (!target_present) { } else if (!target_present) {
// Nothing on screen to point at: the whole surface dims and the card takes a corner. A step whose // 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. // 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; view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0;
} }
// ... and when the target *is* there but could not be measured, the previous hole stands: a frame // ... 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 // can arrive between two layout passes, and emptying the hole there is the spotlight and the card
// jumping to the corner and back. // jumping to the corner and back.
const int hx = app.tour_hole[0]; const int hx = view.hole[0];
const int hy = app.tour_hole[1]; const int hy = view.hole[1];
const int hw = app.tour_hole[2]; const int hw = view.hole[2];
const int hh = app.tour_hole[3]; const int hh = view.hole[3];
place(app.tour_dim[0], 0, 0, w, hy); // above the hole place(view.dim[0], 0, 0, w, hy); // above the hole
place(app.tour_dim[1], 0, hy + hh, w, h - hy - hh); // below it place(view.dim[1], 0, hy + hh, w, h - hy - hh); // below it
place(app.tour_dim[2], 0, hy, hx, hh); // to its left place(view.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(view.dim[3], hx + hw, hy, w - hx - hw, hh); // to its right
place(app.tour_spot, hx, hy, hw, hh); place(view.spot, hx, hy, hw, hh);
// The card, placed clear of the hole whenever the window has room for it. The strip order is the // 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 // order a card is easiest to read in: under the thing it describes, then over it, then in
@@ -316,19 +285,18 @@ void update_tour(App& app) {
int cw = 0; int cw = 0;
int ch = 0; int ch = 0;
graphene_rect_t card_bounds = GRAPHENE_RECT_INIT(0, 0, 0, 0); graphene_rect_t card_bounds = GRAPHENE_RECT_INIT(0, 0, 0, 0);
if (gtk_widget_is_visible(app.tour_card) && gtk_widget_get_width(app.tour_card) > 0 && if (gtk_widget_is_visible(view.card) && gtk_widget_get_width(view.card) > 0 &&
gtk_widget_compute_bounds(app.tour_card, app.tour_overlay, &card_bounds)) { gtk_widget_compute_bounds(view.card, view.overlay, &card_bounds)) {
cw = static_cast<int>(std::ceil(card_bounds.size.width)); cw = static_cast<int>(std::ceil(card_bounds.size.width));
ch = static_cast<int>(std::ceil(card_bounds.size.height)); ch = static_cast<int>(std::ceil(card_bounds.size.height));
} }
if (cw <= 0 || ch <= 0) { if (cw <= 0 || ch <= 0) {
gtk_widget_measure(app.tour_card, GTK_ORIENTATION_HORIZONTAL, -1, nullptr, &cw, nullptr, gtk_widget_measure(view.card, GTK_ORIENTATION_HORIZONTAL, -1, nullptr, &cw, nullptr, nullptr);
nullptr); cw = std::max(kCardWidth, cw - gtk_widget_get_margin_start(view.card) -
cw = std::max(kCardWidth, cw - gtk_widget_get_margin_start(app.tour_card) - gtk_widget_get_margin_end(view.card));
gtk_widget_get_margin_end(app.tour_card)); gtk_widget_measure(view.card, GTK_ORIENTATION_VERTICAL, -1, nullptr, &ch, nullptr, nullptr);
gtk_widget_measure(app.tour_card, GTK_ORIENTATION_VERTICAL, -1, nullptr, &ch, nullptr, nullptr); ch = std::max(0, ch - gtk_widget_get_margin_top(view.card) -
ch = std::max(0, ch - gtk_widget_get_margin_top(app.tour_card) - gtk_widget_get_margin_bottom(view.card));
gtk_widget_get_margin_bottom(app.tour_card));
} }
int cx = 0, cy = 0; int cx = 0, cy = 0;
if (h - (hy + hh) >= ch + 2 * kGap) { if (h - (hy + hh) >= ch + 2 * kGap) {
@@ -352,12 +320,92 @@ void update_tour(App& app) {
cx = std::max(kGap, w - cw - kGap); cx = std::max(kGap, w - cw - kGap);
cy = std::max(kGap, h - ch - kGap); cy = std::max(kGap, h - ch - kGap);
} }
place_natural(app.tour_card, cx, cy); place_natural(view.card, cx, cy);
} }
// --- the main window's tour, and its table of stops --------------------------
namespace {
/// One stop of the *window's* tour. The target is measured, never named by a stored rectangle: the
/// point of re-measuring every frame is that nothing here knows where the widget is.
struct TourStep {
const char* id;
const char* title;
const char* body;
GtkWidget* App::* target;
};
constexpr TourStep kTourSteps[] = {
{"plot", "The plot",
"Every signal you have switched on is drawn here, all rows sharing one time axis. The wheel "
"zooms, a drag pans, and a right-click on a row opens that plot's own menu.",
&App::area_overlay},
{"side", "The side panel",
"The working surface beside the plot. Measure holds the cursors and the measurements; Signals "
"holds the trigger and the list of signals. The buttons in the bar above show and hide it.",
&App::side_box},
{"trigger", "The trigger",
"Describe an event -- a signal, a condition, a level and the window around it -- and arm it: "
"the view then freezes on the first event that matches, however rare it is.",
&App::trigger_body},
{"signals", "The signals",
"Every signal the source declares, each with the switch that acquires it. A signal has to be "
"switched on before it can be plotted, measured or searched.",
&App::signal_list},
{"status", "The status line",
"The stream's own report, once a second: the display mode, the rate arriving, the window on "
"screen and the buffer holding it. The information button opens the counters behind it.",
&App::status_row},
};
constexpr int kTourStepCount = static_cast<int>(sizeof(kTourSteps) / sizeof(kTourSteps[0]));
} // namespace
void build_tour(App& app) {
tour_build(app.tour, app.tour.overlay);
}
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 stops whose target is on screen, in table order. A surface that is not there is not walked
// over -- the point of asking `is_visible()` rather than `get_visible()` is that a widget under a
// hidden parent still reports its own flag as true.
std::vector<TourStop> stops;
for (int i = 0; i < kTourStepCount; ++i) {
GtkWidget* target = app.*(kTourSteps[i].target);
if (target != nullptr && gtk_widget_is_visible(target)) {
stops.push_back({target, kTourSteps[i].title, kTourSteps[i].body});
}
}
if (stops.empty()) return;
tour_begin(app.tour, std::move(stops), nullptr, nullptr);
}
void tour_step(App& app, int delta) { tour_advance(app.tour, delta); }
void tour_dismiss(App& app) { tour_end(app.tour); }
bool tour_active(const App& app) { return app.tour.running; }
int tour_step_count(const App& app) { return static_cast<int>(app.tour.plan.size()); }
int tour_step_index(const App& app) { return app.tour.index; }
void update_tour(App& app) { tour_update(app.tour); }
GtkWidget* tour_target(App& app) { return tour_target(app.tour); }
gboolean tour_autostart(gpointer user) { gboolean tour_autostart(gpointer user) {
App* app = static_cast<App*>(user); App* app = static_cast<App*>(user);
if (app->window == nullptr || app->tour_overlay == nullptr) return G_SOURCE_REMOVE; if (app->window == nullptr || app->tour.overlay == nullptr) return G_SOURCE_REMOVE;
app->tour_auto_started = true; app->tour_auto_started = true;
start_tour(*app); start_tour(*app);
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
@@ -365,7 +413,7 @@ gboolean tour_autostart(gpointer user) {
gboolean tour_start_requested(gpointer user) { gboolean tour_start_requested(gpointer user) {
App* app = static_cast<App*>(user); App* app = static_cast<App*>(user);
if (app->window == nullptr || app->tour_overlay == nullptr) return G_SOURCE_REMOVE; if (app->window == nullptr || app->tour.overlay == nullptr) return G_SOURCE_REMOVE;
// Not `tour_auto_started`: the chooser's entry is the user asking for the tour before the window // Not `tour_auto_started`: the chooser's entry is the user asking for the tour before the window
// existed, and the reading has to say which of the two started it. // existed, and the reading has to say which of the two started it.
app->tour_requested = true; app->tour_requested = true;
@@ -406,21 +454,26 @@ void capture_help_state(App& app) {
std::string ids; std::string ids;
const int hints = context_help_count(app, &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 // 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. // Adwaita does not have is a *blank* control that works and reads as nothing at all. The size is
// the reading a "much tinier" affordance is checked against, and it is measured the way every
// other rectangle here is (`gtk_widget_compute_bounds()`), on the tick while the widget is alive.
GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default()); 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"); const bool icon_ok = theme != nullptr && gtk_icon_theme_has_icon(theme, "dialog-information-symbolic");
char layer[512]; int hint_w = 0;
int hint_h = 0;
context_help_size(app, &hint_w, &hint_h);
char layer[640];
std::snprintf(layer, sizeof(layer), std::snprintf(layer, sizeof(layer),
"contextual=%s hints=%d ids={%s} icon=dialog-question-symbolic(%s); tour=%s steps=%d " "contextual=%s hints=%d ids={%s} icon=dialog-information-symbolic(%s) hint=%dx%d; "
"step=%d auto=%d requested=%d", "tour=%s steps=%d step=%d auto=%d requested=%d",
app.settings.contextual_help ? "on" : "off", hints, ids.empty() ? "" : ids.c_str(), app.settings.contextual_help ? "on" : "off", hints, ids.empty() ? "" : ids.c_str(),
icon_ok ? "ok" : "MISSING", app.tour_running ? "running" : "idle", icon_ok ? "ok" : "MISSING", hint_w, hint_h,
tour_step_count(app), tour_step_index(app), app.tour_auto_started ? 1 : 0, app.tour.running ? "running" : "idle", tour_step_count(app), tour_step_index(app),
app.tour_requested ? 1 : 0); app.tour_auto_started ? 1 : 0, app.tour_requested ? 1 : 0);
app.dbg_context_help = layer; app.dbg_context_help = layer;
app.dbg_tour_steps = tour_step_count(app); app.dbg_tour_steps = tour_step_count(app);
app.dbg_tour_step = tour_step_index(app); app.dbg_tour_step = tour_step_index(app);
app.dbg_tour_visible = app.tour_running ? 1 : 0; app.dbg_tour_visible = app.tour.running ? 1 : 0;
std::string figures; std::string figures;
for (int i = 0; i < kFigureSurfaceCount; ++i) { for (int i = 0; i < kFigureSurfaceCount; ++i) {
+7 -1
View File
@@ -530,7 +530,10 @@ GtkWidget* build_trigger_panel(App& app) {
row_for(ui, "level_row", app.trigger_level_spin); row_for(ui, "level_row", app.trigger_level_spin);
app.trigger_pre_spin = spin(0.0, 99.0, 1.0, 0, 50.0); app.trigger_pre_spin = spin(0.0, 99.0, 1.0, 0, 50.0);
row_for(ui, "pre_row", app.trigger_pre_spin); // The row is kept, not just filled: the contextual help's "trigger_advanced" hint joins it, beside
// the pre-trigger share -- the panel's finer settings, which is what the ask calls the trigger's
// advanced ones.
app.trigger_pre_row = row_for(ui, "pre_row", app.trigger_pre_spin);
app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4, app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4,
app.model->config().trigger_holdoff_s); app.model->config().trigger_holdoff_s);
@@ -564,6 +567,9 @@ GtkWidget* build_trigger_panel(App& app) {
gtk_widget_set_halign(app.events_record_switch, GTK_ALIGN_END); gtk_widget_set_halign(app.events_record_switch, GTK_ALIGN_END);
gtk_widget_set_valign(app.events_record_switch, GTK_ALIGN_CENTER); gtk_widget_set_valign(app.events_record_switch, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(record_row), app.events_record_switch); gtk_box_append(GTK_BOX(record_row), app.events_record_switch);
// Where the contextual help's "events_record" hint joins -- beside the switch, never over it
// (see `ui_context_help.cpp`). Kept on the App because the layer's table names a field.
app.trigger_record_row = record_row;
} }
app.arm_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_arm")); app.arm_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_arm"));
+14 -1
View File
@@ -68,7 +68,20 @@ void install_css() {
".udpscope-tour-dim { background-color: rgba(0, 0, 0, 0.45); }\n" ".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-spot { border: 2px solid @accent_bg_color; border-radius: 8px; }\n"
".udpscope-tour-card { background-color: @window_bg_color; border: 1px solid @borders;" ".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"; " border-radius: 12px; padding: 12px; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.45); }\n"
// The contextual-help "(i)": a *mark*, not a button. Adwaita's menu button is a 34x34 control
// (a 24 px floor plus 5 px of padding) with a 16 px glyph, which beside a heading reads as a
// second toolbar button -- the size the ask calls too big. Measured on libadwaita 1.10: the
// outer `menubutton` node *and* its `button` subnode both carry a floor and padding, and a class
// selector on either alone leaves the other at 34 (`min-width` on the widget itself is ignored
// by `GtkMenuButton`'s measure, and so is a size request -- tried both). Zeroing all three --
// the outer node, the subnode and its glyph -- is what brings it to **16x16**: the icon's own
// size and nothing else, because a `min-width` is a floor, not a cap, so 16 px is where a 16 px
// Adwaita glyph stops. That is the reading `hint=WxH` in the dump prints.
"menubutton.udpscope-hint { min-width: 0; min-height: 0; padding: 0; margin: 0; }\n"
"menubutton.udpscope-hint > button { min-width: 0; min-height: 0; padding: 0; }\n"
"menubutton.udpscope-hint > button > image { min-width: 12px; min-height: 12px;"
" -gtk-icon-size: 12px; }\n";
GtkCssProvider* p = gtk_css_provider_new(); GtkCssProvider* p = gtk_css_provider_new();
gtk_css_provider_load_from_string(p, kCss); gtk_css_provider_load_from_string(p, kCss);
gtk_style_context_add_provider_for_display(gdk_display_get_default(), gtk_style_context_add_provider_for_display(gdk_display_get_default(),