help: default the contextual layer off, and carry the help into the chooser

The contextual help was on by default, so a first run met a window with a "?"
beside every surface it had not asked to have explained; `contextual_help` now
defaults to false and the layer is asked for from the Help menu, from the
chooser's new help menu, or with Ctrl+H.

The source chooser runs before `App` exists, which makes it the window a user
who does not know how to start is looking at, so it now carries the same three
help affordances the window's Help view does: Documentation (the manual is
another process and needs nothing of the app), Enable contextual help (a row
whose check *is* the setting, written to the file the app reads a moment
later), and Guided tour. The tour is drawn on the app's own window, so the
chooser can only record the wish: it sets `Config::tour_pending` -- the value
`main()` owns and hands to both `choose_source()` and `run_ui()` -- and
`on_activate()` starts it once the window is up and laid out, through its own
callback so the readings can tell a request from the first-run auto-start.
The rows are the window's own `menu_button()`/`menu_toggle()`; `install_css()`
therefore runs from `choose_source()` (it is idempotent now) so the popover
gets the `.udpscope-menu` rules before it is shown.

Opening the manual could also fail with a message that blamed the handler
whatever was missing. `help_installed_path()` now resolves `help:<id>` the way
the specification does and the failure alert says which of the two it is: with
the set installed it names the viewer, without it it names the identifier, the
expected installed path and the install command. `show_help()` gained an
optional reporter, which `UDPSCOPE_PROBE_HELP_MS` uses for a new reading --
`[help] launch attempted= done= ok= error='' installed=` -- since a successful
launch leaves no window of ours to observe.

Tested: clean --clean-first build, three warnings as documented; test-ui 13/13
and test-help pass; no Gtk-CRITICAL in the help, helpui and chooser runs.
This commit is contained in:
Martino Ferrari committed 2026-10-14 17:22:03 +02:00
1 parent e140642ed0
commit d8a79b96ba
15 files changed
+463 -68

No files matched your search

+43 -10
View File
@@ -730,7 +730,11 @@ same session twice). Connector settings are stored even for a configuration with
which is why those rows exist at all. The app's
menu has **Configurations…**: the same list for the running source, with *Save this session* —
a name and a note — and a delete button per row. Opening one saves the current session first, so
switching is never the gesture that loses what it switches from.
switching is never the gesture that loses what it switches from. Beside **Preferences…** the chooser
also carries a **help menu** with the window's three help entries — the manual, the contextual-help
toggle and the guided tour — because the chooser is shown to exactly the user who has not started yet
and does not know how to; the tour is started by the app once its window is up (see
[the help section](#the-help-submenu-the-contextual-help-and-the-guided-tour)).
A session written before configurations existed is imported on first use as the configuration
named `default`: copied into the directory, remembered as active, and the old file renamed to
@@ -901,14 +905,32 @@ button is a sibling of the heading's title, not an overlay on its corner, for th
toolbar's icons are not overlaid either: an overlay child sits *on* whatever is under it, and the
one surface whose corner is free (the plot) is the one that uses one.
**Off means gone, not greyed.** The toggle removes the buttons rather than disabling them, and
which buttons exist is decided by a data key rather than a list of pointers, so the math editor --
whose heading is created afresh every time it opens -- cannot leave a stale pointer behind. The
setting is `contextual_help` in `settings.toml`, written by
**Off means gone, not greyed, and off is the default.** The toggle removes the buttons rather than
disabling them, and which buttons exist is decided by a data key rather than a list of pointers, so
the math editor -- whose heading is created afresh every time it opens -- cannot leave a stale
pointer behind. The setting is `contextual_help` in `settings.toml`, written by
`Settings`/`load_settings()`/`save_settings()`: the same file, the same three functions and the same
`~/.config/udpscope/settings.toml` the Preferences dialog writes. The dialog's own rows start from
`load_settings()`, so a save from there carries the two help keys through untouched; the Help menu's
row is the only place that states `contextual_help`, which is why the row's check *is* the setting.
`~/.config/udpscope/settings.toml` the Preferences dialog writes. **A fresh install has no such key
and the default is `false`**, so a first run shows the window as anyone would expect it and the layer
is asked for -- from the Help menu, from the source chooser's own help menu, or with `Ctrl+H`. The
dialog's own rows start from `load_settings()`, so a save from there carries the two help keys
through untouched; the Help menu's row is the only place in the *window* that states `contextual_help`
(which is why the row's check *is* the setting), and the chooser's row states it too, before any
window exists.
**The source chooser carries the same three affordances, because it is shown to exactly the user who
has not started yet.** Its header bar has a help `GtkMenuButton` beside *Preferences*, whose rows are
the window's own `menu_button()`/`menu_toggle()` -- the same shape, and the same `.udpscope-menu`
stylesheet, whose rules `choose_source()` installs before building them since the app installs them
later. *Documentation* opens the manual directly (it is another process and needs nothing of the app),
*Enable contextual help* writes the settings file the app reads a moment later, and *Guided tour*
cannot start anything -- the tour is drawn on the app's window, which does not exist yet -- so it sets
**`Config::tour_pending`**, the value `main()` owns and hands to both the chooser and the front end
(`open_waveform` crosses the same way). Half a second and a half after the window is up, the same
delay the first-run auto-start uses, the pending request starts the tour; it sets `tour_requested`
rather than `tour_auto_started`, so the readings say which of the two it was. The chooser's rows show
**no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds them, and this dialog
binds none.
**The guided tour is five steps on a `GtkOverlay`** inserted above the window's content: the plot,
the side panel, the trigger, the signals and the status line. There is no popover anywhere in it and
@@ -2439,7 +2461,14 @@ identifier `org.udpscope.app` is the desktop file's own basename, so it is the `
GApplication id and the install directory at once. The set is installed by an `install()` rule to
`share/help/C/org.udpscope.app/` — the path the freedesktop Help System Specification's `help:` URI
resolves — and `show_help()` hands `help:org.udpscope.app` to whatever the desktop registered for the
scheme (Yelp on GNOME), reporting a launch failure with an alert rather than failing silently.
scheme (Yelp on GNOME), reporting a launch failure with an alert rather than failing silently. That
alert says **which** thing is missing, because the two have different fixes: `help_installed_path()`
looks for the document under `$XDG_DATA_HOME/help/<lang>/org.udpscope.app/` then
`$XDG_DATA_DIRS/help/<lang>/org.udpscope.app/`, so with the set installed the alert names the missing
*viewer* (`yelp`), and without it the alert names the identifier, the expected installed path and the
install command — `cmake --install build --prefix ~/.local`, which writes
`~/.local/share/help/C/org.udpscope.app/`. A build tree has neither the installed copy nor a
`help:` handler, and **either is enough to make `F1` do nothing**; that is the failure this names.
`cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files:
`yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render;
`yelp-check validate` is left out because it fetches the Mallard RelaxNG schema over the network.
@@ -2447,7 +2476,11 @@ scheme (Yelp on GNOME), reporting a launch failure with an alert rather than fai
(GTK4 has to be forced to the Cairo renderer or X11 reads the surface back blank).
`UDPSCOPE_PROBE_HELP_MS` asserts the set is *present in the tree* — its
`[help] topics=… index=… figures=…` reading — rather than parsing pages, since they are files now
and not resources. Mallard is translatable with `itstool`, but only the `C` set is authored here.
and not resources, and it **launches the manual too**: `[help] launch attempted=1 done=1 ok=1
error='' installed=/home/…/.local/share/help/C/org.udpscope.app` is the reading, `ok=1` meaning the
`help:` URI reached a registered handler and not that a window appeared (Yelp is another process and
cannot map one headlessly). Mallard is translatable with `itstool`, but only the `C` set is authored
here.
**The status bar is two tiers.** The bar itself shows one glance line: the activity badge, the
display state (one word from a fixed set — `connecting`, `live`, `paused`, `armed`, `capturing`,
+38 -2
View File
@@ -127,7 +127,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
honest without knowing which dialog it is. One `GtkWidget*` (or `AdwDialog*`) per window, nulled
in its own close handler, is the shape that prevents the double-open defect. `close_help_windows()`
(misnamed now) closes just the two dialogs; a failed *launch* is reported by the launcher's own
callback with a `GtkAlertDialog`, so a click that cannot open the manual says so.
callback with a `GtkAlertDialog`, so a click that cannot open the manual says so. That callback is
also `show_help()`'s **optional reporter** (`std::function<void(bool ok, const std::string& error)>`)
— the one hook that runs on the success path, where no window of ours is left to observe; the probe
uses it and nothing else does.
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at
configure time (so `add_executable` can name the file) and again on every build through
`udpscope_build_info` (so the commit and the timestamp belong to the binary that is running).
@@ -149,7 +152,21 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
→ `org.udpscope.app`), so it is the `.desktop` name, the GApplication id and the install
directory at once; `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that one
place. `show_help()` opens `help:<id>` with `GtkUriLauncher` (Yelp runs in its own process, so
nothing here holds a pointer to it), and its failure callback shows a `GtkAlertDialog`.
nothing here holds a pointer to it), and its failure callback shows a `GtkAlertDialog`. That
failure is **careful about which thing is missing**: `gtk_uri_launcher_launch_finish()` reports a
launch that could not be handed over — the *handler's* absence — and cannot tell that from a
document that was never installed, so `help_installed_path()` walks the specification's own
`$XDG_DATA_HOME/help/<lang>/<id>/` then `$XDG_DATA_DIRS/help/<lang>/<id>/` for an `index.page` and
the dialog says which one it is: with the manual there, install a viewer (for example `yelp`); with
it absent, the identifier, the expected installed path and the install command
(`cmake --install build --prefix ~/.local`, which writes `~/.local/share/help/C/<id>/`) — and a
viewer as well, the second possibility the error alone cannot rule out. Reporting a missing
*document* as a missing *handler* is the defect that shape avoids.
`UDPSCOPE_PROBE_HELP_MS` launches it too, and its `[help] launch attempted= done= ok= error=''
installed=` line is the reading — `ok=1` is the URI handed to a viewer and not a window on screen,
since Yelp is another process and cannot map one headlessly; `installed=` sits beside it because a
failing `ok=0` has to be read against whether the document was there at all.
`UDPSCOPE_PROBE_HELP_MS` no longer parses pages — they are files, not markup in a resource — so
its `[help]` line asserts the set is *there*: `topics` (the `.page` files other than
@@ -163,6 +180,25 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
(GTK4 must be forced to the Cairo renderer, `GSK_RENDERER=cairo`, or the X11 surface reads back
blank). Mallard is translatable with `itstool`, but there is no i18n set-up here: only the `C`
set is authored.
- **The chooser carries the same help affordances as the window, and the tour request crosses as a
value — not a global.** The source chooser runs *before* `App` exists, so a user who has not started
yet is the one least able to find the help; its header bar therefore has a help `GtkMenuButton`
(the `.ui` holds the button, code holds the rows) whose three entries are the window's Help view's
the same way — `ui_impl::menu_button()`/`menu_toggle()`, the app's own row shape — with the
`.udpscope-menu` class on the popover. That class's rules only exist once `install_css()` has run, so
`choose_source()` calls it before building the rows (`install_css()` is idempotent precisely because
it now has two callers; the app's `on_activate()` still calls it and is a no-op by then). The rows
show **no accelerators**: the window's Help view prints `F1`/`Ctrl+T` because the window binds them,
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
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
app's own window — so it 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
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
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.
- **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
position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column
+40 -12
View File
@@ -395,17 +395,21 @@ Two lines of the self-check output are assertions in disguise:
on the first tick for the same reason the toolbar's are. **Both must read `(ok)`**: the page titles are
empty on purpose, so an icon that does not resolve leaves a *blank* pill, and a blank pill looks
exactly like a page with nothing in it.
- `help layer: contextual=on hints=8 ids={events plot status cursors measures trigger signals window} icon=dialog-question-symbolic(ok); tour=idle steps=5 step=0 auto=1` (in the exit
- `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
dump, beside `switcher:`) — the help affordance layer as the last tick found it. **`hints=` must equal
the number of ids**, and the ids are the surfaces the contextual help is on: the eight of the window
itself, and `math` only while the math editor is open (its heading lives in a builder that is rebuilt
on every opening, so it is not one of the window's own). `contextual=off` beside `hints=0` is the
toggle's other state — **off is *gone*, not greyed**, so a non-zero count with `off` is a row that hid
its buttons instead of removing them. **`icon=` must read `(ok)`**: a `?"` whose name Adwaita lacks is a
on every opening, so it is not one of the window's own). The layer is **off by default**
(`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` 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
*blank* control that still works, the failure the pane [edit] buttons already paid for once. `tour=`
is `running`/`idle` with the plan's `steps=` and the current `step=`, and `auto=1` is the first-run
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`.
against the same config directory reads `auto=0` — and `requested=1` is the **chooser's** "Guided tour"
(`Config::tour_pending`) having been carried into this run instead, which is `auto=0` because it is not
the auto-start.
- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… side_switcher=… side_stack=… window_button=… status_label=… header_bar=… plot=…` (in the exit
dump, beside `help layer:`) — the **window-coordinate** rectangle of each surface the help's annotated
figures label, `x,y,width,height`, or `-1,-1,0,0` for a surface that is **not on screen**. It is
@@ -634,7 +638,7 @@ Two lines of the self-check output are assertions in disguise:
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
- `[help] topics=14 index=1 figures=3 dir=/…/help/C/org.udpscope.app; shortcuts: app=14 plot=14 tables=28 ui=28 ui_accel=21 ui_icon=7 ui_bare=0; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — the manual is a Mallard set on disk now, not a GResource in the binary, so this is a **file-system reading**: `topics` is the `.page` files other than `index.page`, `index` must be 1, and `figures` counts the screenshots under `figures/`. **`topics=0 index=0` means the manual is missing** from `dir` (the source directory the build was configured from, `UDPSCOPE_HELP_DIR`) — the finding the old `parsed != pages` used to name. The manual itself opens in Yelp, another process, which this probe does not and cannot exercise. The `shortcuts:` numbers are the shortcut overview four ways.
- `[help] topics=14 index=1 figures=3 dir=/…/help/C/org.udpscope.app; shortcuts: app=14 plot=14 tables=28 ui=28 ui_accel=21 ui_icon=7 ui_bare=0; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — the manual is a Mallard set on disk now, not a GResource in the binary, so this is a **file-system reading**: `topics` is the `.page` files other than `index.page`, `index` must be 1, and `figures` counts the screenshots under `figures/`. **`topics=0 index=0` means the manual is missing** from `dir` (the source directory the build was configured from, `UDPSCOPE_HELP_DIR`) — the finding the old `parsed != pages` used to name. The manual itself opens in Yelp, another process, which the probe now launches and reports (the `[help] launch` line below). The `shortcuts:` numbers are the shortcut overview four ways.
`app` and `plot` are the rows in the C tables -- what the application actually *handles* -- and `tables`
is their sum. `ui`, `ui_accel`, `ui_icon` and `ui_bare` are what `src/ui/shortcuts.ui` *declares*, read
out of the resource with a markup parse rather than off the built widgets, because a tree walk counts
@@ -642,7 +646,8 @@ every row twice: `GtkShortcutsWindow` reaches each one from more than one place
**`ui` must equal `tables`** -- two lists of one thing, and a row in one but not the other is a gesture
that works and is not documented, or is documented and does nothing -- and **`ui_bare` must be zero**: a
row with no accelerator and no icon is a cell reading "No Shortcut". `ui_accel + ui_icon == ui`, so those
two also catch a row whose accelerator went missing. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of the two dialogs built, presented and closed in one run as it is a state check: they are closed by **two different calls**, so a mismatched pair crashes or leaks here. One is a `GtkWindow` (the shortcut overview, `gtk_window_close()`/`::close-request`); the About dialog is an `AdwDialog` and closes with `adw_dialog_close()`, cleared on `::closed`. The manual is *not* one of them — it opens in Yelp, a separate process, so there is nothing here to open, read back or close.
two also catch a row whose accelerator went missing. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of the two dialogs built, presented and closed in one run as it is a state check: they are closed by **two different calls**, so a mismatched pair crashes or leaks here. One is a `GtkWindow` (the shortcut overview, `gtk_window_close()`/`::close-request`); the About dialog is an `AdwDialog` and closes with `adw_dialog_close()`, cleared on `::closed`. The manual is *not* one of them — it opens in Yelp, a separate process, so there is no window of ours to read back or close; the line below is the reading of that path instead.
- `[help] launch attempted=1 done=1 ok=1 error='' installed=/home/…/.local/share/help/C/org.udpscope.app` (stderr, `UDPSCOPE_PROBE_HELP_MS`, a moment after the line above) — the **launch of the manual**, the one part of the help path no other reading reaches: a successful launch leaves no window of ours, so nothing else can say whether `show_help()` did anything at all. `attempted=` is set at the call (`0` would be a probe that never got as far as calling it); `done=` says the asynchronous callback ran, and **`done=0` is the reading for a launch that never called back**; `ok=1` is the `help:` URI handed to a viewer, `ok=0` with the launcher's `error=` text is a launch that failed. **`ok` is not "a window appeared"**: Yelp is another process, and under a headless X server it cannot map one — the launch is *this* process's half (the URI reached a registered handler), which is exactly what the desktop does when a user presses F1, and what an absent handler or an uninstalled viewer breaks. `installed=` is `help_installed_path()`'s answer — the `$XDG_DATA_HOME`/`$XDG_DATA_DIRS` directory the document was found in, or `(not found)` — and it is what a failing `ok=0` should be read beside, because the launch error alone cannot tell a missing document from a missing handler. On a machine with the set installed by `cmake --install build --prefix ~/.local` and `yelp` present, the line reads `ok=1 … installed=~/.local/share/help/C/org.udpscope.app`. A **failure** leaves the launcher's `GtkAlertDialog` up on purpose — that is what a user gets, and the probe must not report a failed path as a silence.
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
@@ -671,7 +676,10 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
run against the same config directory, which is the assertion that the flag persisted. Then:
- `[helpui] contextual on: hints=8 ids={events plot status cursors measures trigger signals window}` —
the eight "?" affordances, by id. The layer's ninth surface, `math`, is not here: its host exists
only while the math editor window is open.
only while the math editor window is open. **On a fresh config this reads `hints=0 ids={}`**, 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.
- `[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
promises: `entry 1 'Keyboard shortcuts' accel='Ctrl+/'`, `entry 2 'Documentation' accel='F1'`,
@@ -679,7 +687,9 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
every row** is the assertion; the view is entered by emitting `::clicked` on the menu's own `Help`
row, so the row's handler is exercised and not just the stack.
- `[helpui] help view: rows=5 expected=5 rows_bad=0 back=1 toggle=1 entries='…'` — the summary, with
`toggle=1` being the contextual-help row's check, which *is* `settings.contextual_help`.
`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
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
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
@@ -1088,7 +1098,10 @@ configuration that carries settings, is the defect this exists for — a saved h
## `[sources] waveforms` and the waveform load
```
[sources] chooser ui: page='sources' title='Sources' icon=yes page='waveforms' title='Waveforms' icon=yes visible='sources' switcher_wired=1 buttons=[prefs=1 new_host=1 open=1 cancel=1]
[sources] chooser ui: page='sources' title='Sources' icon=yes page='waveforms' title='Waveforms' icon=yes visible='sources' switcher_wired=1 buttons=[prefs=1 new_host=1 open=1 cancel=1] help=[button=1 docs=1 tour=1 toggle=1 on=0]
[sources] chooser help: tour requested
[sources] chooser help: tour_pending=1
[sources] chooser help: toggle=1 setting=1 file=/tmp/…/udpscope/settings.toml
[sources] waveforms: 1 recent /tmp/rt.npy(ok) page=waveforms
[sources] picked waveform /tmp/rt.npy
[udpscope] waveform loaded: /tmp/rt.npy signals=4 samples=20001 rows=20001 decim=1 span=2.000000s rate=10000 time=[-2.000000..0.000000]
@@ -1097,7 +1110,7 @@ configuration that carries settings, is the defect this exists for — a saved h
| field | value it must read | the defect a wrong reading names |
| --- | --- | --- |
| `chooser ui:` | both pages with a `title` and an `icon=yes`, `switcher_wired=1`, and all four buttons `1` | the chooser's `.ui` losing an id: the code names the missing one and refuses the window, and this line is the frame that *did* build -- a page with `icon=NO` is one the switcher cannot draw |
| `chooser ui:` | both pages with a `title` and an `icon=yes`, `switcher_wired=1`, all four buttons `1`, and `help=[button=1 docs=1 tour=1 toggle=1 on=0]` | the chooser's `.ui` losing an id: the code names the missing one and refuses the window, and this line is the frame that *did* build -- a page with `icon=NO` is one the switcher cannot draw, and a `help=` field at `0` is a help row (or the button that opens it) the dialog no longer carries |
| `waveforms:` | one entry per remembered export, with `(ok)` or `(missing)`, and `page=waveforms` once the page is shown | the Waveforms page not built, or a stale path offered as if it were openable |
| `picked waveform` | the selected path | a pick that carries no file: `--open` is what the mode reads |
| `signals=`, `rows=` | the export's `cols=` and `rows=` | a reader mis-slicing its records, or the columns bound to the wrong channels |
@@ -1106,6 +1119,21 @@ configuration that carries settings, is the defect this exists for — a saved h
| `span=`, `rate=`, `time=[…]` | the exported window and rate, `[-window, 0]` | the plan not covering the whole file (the tail arrives as the whole picture) or the display epoch not pinned to the file's last stamp |
| `frames=0 packets=0` beside `channels=N` | 0 — no acquisition | a socket opened for a file, which would also make the starvation notice live |
The chooser's **help menu** — `Documentation`, `Guided tour` and `Enable contextual help`, the three
affordances the window's Help view carries, moved in front of a user who has not started yet — has two
probe legs, both of which then fall through to the normal first-row pick so the run they affect is the
run they report:
- `UDPSCOPE_PROBE_CHOOSER_TOUR=1` emits `::clicked` on `Guided tour`, and `[sources] chooser help:
tour_pending=1` reads the request back out of the `Config` the run will be handed. **`tour_pending=1`
is the assertion**: the tour is drawn on a window that does not exist yet, so the entry can only work
by setting that field, and the reading it *started* is the exit dump's `help layer:` — `tour=running
… auto=0 requested=1`, `requested=1` being this path (see `help layer:` above).
- `UDPSCOPE_PROBE_CHOOSER_HELP=1` sets the `Enable contextual help` row active and reads the settings
file back: `toggle=1 setting=1 file=<settings.toml>`. **`setting=1` beside `toggle=1`** is the pair
that matters — the row *is* the setting, and the app is built from the file and not from the dialog,
so the file is where it has to have landed.
## `[drop-probe]`
```
+7
View File
@@ -59,6 +59,13 @@ struct Config {
/// live source. File mode and source mode are exclusive -- there is no acquisition behind a file,
/// and the chooser's Waveforms page sets this rather than the connection fields.
std::string open_waveform;
/// The source chooser's "Guided tour" entry. The tour is drawn on the application's own window,
/// which does not exist while the chooser runs, so the request rides on the one value `main()`
/// owns and hands to both the chooser and the front end (`open_waveform` and the preferences'
/// relative time cross the same way) and the window starts the tour once it is up and laid out.
/// It is *not* a preference: it describes this request and nothing else, so nothing persists it.
bool tour_pending = false;
};
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
+86 -23
View File
@@ -3,7 +3,9 @@
#include <adwaita.h>
#include <functional>
#include <string>
#include <vector>
#include "build_info.hpp"
#include "layout_store.hpp"
@@ -44,27 +46,55 @@ void on_dialog_closed(AdwDialog* dialog, gpointer slot) {
*static_cast<AdwDialog**>(slot) = nullptr;
}
/// The launch is asynchronous and can fail -- no help viewer installed, no handler for the
/// `help:` scheme, or a document no viewer can find -- and it runs in another process, so
/// nothing raises a window of ours. Report the failure here rather than leaving a click that
/// silently did nothing; the detail names the identifier a viewer looks for.
/// What an asynchronous launch needs to survive the call: the window it is attributed to (held,
/// because the call outlives the stack frame that made it) and the caller's reporter, if it asked
/// for one. One small struct rather than two `gpointer`s, because a callback that takes both through
/// one `user_data` still has to get them there.
struct HelpLaunch {
GtkWindow* parent = nullptr;
std::function<void(bool, const std::string&)> done;
};
/// The launch failed. Report it rather than leaving a click that silently did nothing -- and say
/// *which* thing is missing: `gtk_uri_launcher_launch_finish()` reports that the URI could not be
/// handed over, which is the handler's absence, while a document that is not installed is a
/// different failure the launcher cannot tell it from. `help_installed_path()` settles that from the
/// file system, so a missing *document* is not reported as a missing *handler*: with the manual
/// installed the message names the viewer, and without it the message names the document, its
/// expected path and the install command -- and a viewer as well, because that second possibility
/// cannot be ruled out from here.
void on_help_launched(GObject* source, GAsyncResult* result, gpointer user) {
GtkWindow* parent = user != nullptr ? GTK_WINDOW(user) : nullptr;
auto* launch = static_cast<HelpLaunch*>(user);
GError* error = nullptr;
if (!gtk_uri_launcher_launch_finish(GTK_URI_LAUNCHER(source), result, &error)) {
const bool ok = gtk_uri_launcher_launch_finish(GTK_URI_LAUNCHER(source), result, &error);
if (!ok) {
const std::string installed = help_installed_path();
GtkAlertDialog* alert = gtk_alert_dialog_new("Could not open the help manual");
const std::string detail =
std::string(error->message) +
"\n\nThe manual is installed under $XDG_DATA_DIRS/help/C/help:" UDPSCOPE_HELP_NAME
"; a desktop help viewer (for example `yelp`) that handles the `help:` URI is needed "
"to read it.";
gtk_alert_dialog_set_detail(alert, detail.c_str());
gtk_alert_dialog_show(alert, parent);
g_object_unref(alert);
g_error_free(error);
std::string detail = error != nullptr ? error->message : std::string("the launch failed");
detail += "\n\n";
if (!installed.empty()) {
detail += "The manual is installed at " + installed +
", so what is missing is a viewer for the `help:` URI it is opened with: install "
"one (for example `yelp`) or make it the desktop's default for that scheme.";
} else {
detail += "The manual does not look installed: nothing named \"help:" UDPSCOPE_HELP_NAME
"\" was found under $XDG_DATA_HOME/help/<lang>/" UDPSCOPE_HELP_NAME "/ or "
"$XDG_DATA_DIRS/help/<lang>/" UDPSCOPE_HELP_NAME "/. Install it with\n\n"
" cmake --install build --prefix ~/.local\n\n"
"which writes ~/.local/share/help/C/" UDPSCOPE_HELP_NAME "/ -- the directory "
"`help:" UDPSCOPE_HELP_NAME "` resolves to. If there is no `help:` viewer either, "
"install one as well (for example `yelp`).";
}
// `parent` was referenced for the duration of the async call.
if (parent != nullptr) g_object_unref(parent);
gtk_alert_dialog_set_detail(alert, detail.c_str());
gtk_alert_dialog_show(alert, launch->parent);
g_object_unref(alert);
}
if (launch->done) {
launch->done(ok, ok || error == nullptr ? std::string() : std::string(error->message));
}
if (launch->parent != nullptr) g_object_unref(launch->parent);
delete launch;
if (error != nullptr) g_error_free(error);
}
} // namespace
@@ -108,6 +138,37 @@ const char* about_icon_name() {
return adw_about_dialog_get_application_icon(ADW_ABOUT_DIALOG(g_about));
}
std::string help_installed_path() {
// The freedesktop Help System Specification's own search order: `$XDG_DATA_HOME` first, then every
// `$XDG_DATA_DIRS` entry in order. Yelp resolves `help:<id>` the same way, so this is a reading of
// where the document *would* be found rather than a second, private convention.
std::vector<std::string> roots;
roots.emplace_back(g_get_user_data_dir() != nullptr ? g_get_user_data_dir() : "");
const char* const* dirs = g_get_system_data_dirs();
for (int i = 0; dirs != nullptr && dirs[i] != nullptr; ++i) roots.emplace_back(dirs[i]);
for (const std::string& root : roots) {
if (root.empty()) continue;
const std::string base = root + "/help";
GError* error = nullptr;
GDir* dir = g_dir_open(base.c_str(), 0, &error);
if (dir == nullptr) {
if (error != nullptr) g_error_free(error);
continue;
}
// `<lang>` is a directory of its own (`C`, `en`, `it`, ...), so the document is looked for one
// level below the `help` root rather than straight under it.
while (const char* lang = g_dir_read_name(dir)) {
const std::string candidate = base + "/" + lang + "/" UDPSCOPE_HELP_NAME;
if (g_file_test((candidate + "/index.page").c_str(), G_FILE_TEST_IS_REGULAR)) {
g_dir_close(dir);
return candidate;
}
}
g_dir_close(dir);
}
return std::string();
}
HelpReport help_self_check() {
HelpReport report;
@@ -141,14 +202,16 @@ HelpReport help_self_check() {
return report;
}
void show_help(GtkWindow* parent) {
void show_help(GtkWindow* parent, std::function<void(bool, const std::string&)> done) {
// The freedesktop `help:` URI, handed to whatever the desktop registered for it -- Yelp on
// GNOME. The launch is asynchronous, so the parent is referenced across it and released in
// the callback; `parent` may legitimately be nullptr (the chooser runs before any window
// exists).
// GNOME. The launch is asynchronous, so both the parent and the reporter are carried across it in
// a heap `HelpLaunch` and released in the callback; `parent` may legitimately be nullptr (the
// chooser runs before any window exists).
auto* launch = new HelpLaunch;
launch->parent = parent != nullptr ? GTK_WINDOW(g_object_ref(parent)) : nullptr;
launch->done = std::move(done);
GtkUriLauncher* launcher = gtk_uri_launcher_new("help:" UDPSCOPE_HELP_NAME);
gtk_uri_launcher_launch(launcher, parent, nullptr, on_help_launched,
parent != nullptr ? g_object_ref(parent) : nullptr);
gtk_uri_launcher_launch(launcher, parent, nullptr, on_help_launched, launch);
g_object_unref(launcher);
}
+15 -1
View File
@@ -4,6 +4,7 @@
#include <gtk/gtk.h>
#include <cstddef>
#include <functional>
#include <string>
namespace udpscope {
@@ -23,7 +24,20 @@ const char* about_icon_name();
/// window of our own. `parent` is the window the launch is attributed to (and the one an
/// error is reported on); the viewer is a separate process, so nothing here can be closed
/// with `close_help_windows()`.
void show_help(GtkWindow* parent);
///
/// `done`, when the caller gives one, is called with the *launch*'s outcome: whether the
/// `help:` URI was handed to a viewer, and the error text when it was not. It says nothing
/// about the viewer itself — that process may fail for its own reasons and this side cannot
/// see them — so it is the self-check's reading of the path and not a promise that a window
/// appeared. The failure alert is shown either way; the callback is an extra, not instead.
void show_help(GtkWindow* parent, std::function<void(bool ok, const std::string& error)> done = {});
/// Where `help:<id>` resolves to on this system: the first of `$XDG_DATA_HOME/help/<lang>/<id>/`
/// and `$XDG_DATA_DIRS/help/<lang>/<id>/` that holds an `index.page`, or an empty string when no
/// such directory does. This is the specification's own resolution, and the distinction a launch
/// failure turns on: a missing *document* (install it) and a missing *handler* (install a viewer)
/// are different problems with different fixes, and only the file system can tell them apart.
std::string help_installed_path();
/// What UDPSCOPE_PROBE_HELP_MS reports about the manual *in the tree*. `topics` is the
/// number of `<id>.page` files other than `index.page`, and `index` says whether
+4 -3
View File
@@ -84,9 +84,10 @@ bool save_settings(const Settings& settings) {
out << "event_cache_max = " << settings.event_cache_max << "\n";
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
out << "theme = \"" << settings.theme << "\"\n";
// The two help keys are the *app's* memory rather than the dialog's: the contextual-help toggle in
// the Help menu writes the first, and the first-run tour writes the second. `read_rows()` in the
// preferences starts from `load_settings()`, so a dialog save carries them through untouched.
// The two help keys are the *app's* memory rather than the dialog's: the contextual-help rows --
// the Help menu's and the source chooser's own -- write the first, and the first-run tour writes the
// second. `read_rows()` in the preferences starts from `load_settings()`, so a dialog save carries
// them through untouched.
out << "contextual_help = " << (settings.contextual_help ? "true" : "false") << "\n";
out << "tour_seen = " << (settings.tour_seen ? "true" : "false") << "\n";
out.flush();
+5 -3
View File
@@ -33,9 +33,11 @@ struct Settings {
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
std::string theme = "auto";
/// The contextual-help layer: a small "?" beside each surface it can explain. On by default -- an
/// affordance nobody can find is not help -- and off is a decision that survives a restart.
bool contextual_help = true;
/// The contextual-help layer: a small "?" beside each surface it can explain. **Off by default** --
/// a first run shows the interface as a user expects it and the layer is asked for, from the Help
/// menu or from the chooser's own help menu, by whoever wants it; on is then a decision that
/// survives a restart.
bool contextual_help = false;
/// Whether the guided tour has been offered. A first run has no file carrying this key, so the tour
/// starts itself once; the flag is written when it *starts*, not when it finishes, because a user who
/// skips it has still seen it and a second launch with the same tour would be a nuisance.
+132 -6
View File
@@ -5,8 +5,13 @@
#include "waveform.hpp"
#include "help_window.hpp"
#include "layout_store.hpp"
#include "settings.hpp"
// The app's shared chrome -- `ui_impl::menu_button()`/`menu_toggle()` and `install_css()` -- so the
// chooser's help menu is the *same* menu the window's Help view is, not a second one that merely
// looks like it. Heavy header, but a row shape copied here would be a row shape that drifts.
#include "ui_app.hpp"
#include "ui_prefs.hpp"
#include <adwaita.h>
@@ -127,6 +132,15 @@ struct Chooser {
/// The preferences, for the button below. Same reasoning as `form` above: a member of the chooser
/// rather than a heap struct, so nothing can outlive the stack frame that owns it.
ui_impl::PrefsForm prefs;
/// The help menu: the `.ui`'s own `GtkMenuButton` and the three rows built onto it. The rows are
/// code for the same reason every other row here is -- each carries a handler, and the toggle
/// carries the value the settings file holds -- while the button that opens them is the file's, so
/// a `.ui` that lost it is caught by the null check below rather than by an empty popover.
GtkWidget* help_button = nullptr;
GtkWidget* help_docs = nullptr;
GtkWidget* help_tour = nullptr;
GtkWidget* help_toggle = nullptr;
GtkWidget* help_popover = nullptr;
};
@@ -596,14 +610,90 @@ void open_chooser_preferences(Chooser& c) {
});
}
/// The chooser's help menu: the same three affordances the window's Help view carries, because a
/// user who has not started yet is exactly the one who does not know how to start. The rows are
/// `ui_impl::menu_button()`/`menu_toggle()` -- the app's own menu-row shape, so the two menus read
/// as one program -- and the popover carries the same `.udpscope-menu` class, which is what trims
/// the rows; `install_css()` therefore runs before this, in `choose_source()`.
///
/// No row shows an accelerator. The window's Help rows show "F1"/"Ctrl+T" because the window handles
/// those keys; this dialog binds none of them, and a shortcut printed beside an action that does not
/// answer to it is the "documented and does nothing" failure the shortcut tables are checked for.
void build_chooser_help(Chooser& c) {
c.help_popover = gtk_popover_new();
gtk_widget_add_css_class(c.help_popover, "udpscope-menu");
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_top(box, 6);
gtk_widget_set_margin_bottom(box, 6);
gtk_widget_set_margin_start(box, 6);
gtk_widget_set_margin_end(box, 6);
c.help_docs = ui_impl::menu_button("Documentation", nullptr);
gtk_widget_set_tooltip_text(c.help_docs,
"the udpscope manual, opened in the desktop help viewer");
g_signal_connect(c.help_docs, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
Chooser& ch = *static_cast<Chooser*>(user);
gtk_popover_popdown(GTK_POPOVER(ch.help_popover));
// No window of the app exists yet: the manual is a separate process reached
// through the `help:` URI, so this needs no more of the app than the chooser
// itself. The parent may be null, and this dialog is the right attribution.
show_help(GTK_WINDOW(ch.window));
}),
&c);
gtk_box_append(GTK_BOX(box), c.help_docs);
c.help_tour = ui_impl::menu_button("Guided tour", nullptr);
gtk_widget_set_tooltip_text(c.help_tour,
"walk over the plot, the side panel, the trigger, the signals and "
"the status line, once the window is up");
g_signal_connect(c.help_tour, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
Chooser& ch = *static_cast<Chooser*>(user);
gtk_popover_popdown(GTK_POPOVER(ch.help_popover));
// The tour lives on the window the app builds, which does not exist yet, so the
// request rides on the `Config` this call was handed and the app starts it once
// the window is up -- see `Config::tour_pending`.
ch.cfg->tour_pending = true;
std::fprintf(stderr, "[sources] chooser help: tour requested\n");
std::fflush(stderr);
}),
&c);
gtk_box_append(GTK_BOX(box), c.help_tour);
// 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
// built. The chooser cannot re-sync anything (there is no "?" layer here), so the handler only has
// to leave the setting where the app will read it.
c.help_toggle = ui_impl::menu_toggle("Enable contextual help", nullptr,
load_settings().contextual_help);
gtk_widget_set_tooltip_text(c.help_toggle,
"a small \u201c?\u201d beside each surface of the main window, saying "
"what that surface is for");
g_signal_connect(c.help_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* button, gpointer) {
Settings s = load_settings();
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
// not own are carried through from the file rather than dropped.
if (!save_settings(s)) {
std::fprintf(stderr, "[sources] chooser help: could not write %s\n",
settings_path().c_str());
std::fflush(stderr);
}
}),
&c);
gtk_box_append(GTK_BOX(box), c.help_toggle);
gtk_popover_set_child(GTK_POPOVER(c.help_popover), box);
gtk_menu_button_set_popover(GTK_MENU_BUTTON(c.help_button), c.help_popover);
}
/// `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
/// dialog built, filled and destroyed is as much the point as the values, the same way
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
/// The chooser's frame, read back from the `.ui` it was built from: the two pages by name and title, the
/// switcher wired to the stack, and the four buttons the code resolved. `[help] ui=` does the same against
/// its tables -- the frame is what the file holds, so the frame is what is read, while the rows a user sees
/// are read by the probes that drive them.
/// switcher wired to the stack, the four buttons the code resolved, and the help menu's three rows.
/// `[help] ui=` does the same against its tables -- the frame is what the file holds, so the frame is
/// what is read, while the rows a user sees are read by the probes that drive them.
void report_chooser_ui(const Chooser& c) {
std::fprintf(stderr, "[sources] chooser ui:");
for (GtkWidget* w = gtk_widget_get_first_child(c.page_stack); w != nullptr;
@@ -617,14 +707,21 @@ void report_chooser_ui(const Chooser& c) {
adw_view_stack_page_get_icon_name(page) != nullptr ? "yes" : "NO");
}
std::fprintf(stderr,
" visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%d]\n",
" 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",
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_STACK(c.page_stack)
? 1
: 0,
c.prefs_button != nullptr ? 1 : 0, c.new_host != nullptr ? 1 : 0,
c.waves_open != nullptr ? 1 : 0, c.cancel_button != nullptr ? 1 : 0);
c.waves_open != nullptr ? 1 : 0, c.cancel_button != nullptr ? 1 : 0,
c.help_button != nullptr ? 1 : 0, c.help_docs != nullptr ? 1 : 0,
c.help_tour != nullptr ? 1 : 0, c.help_toggle != nullptr ? 1 : 0,
c.help_toggle != nullptr &&
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(c.help_toggle))
? 1
: 0);
std::fflush(stderr);
}
@@ -638,6 +735,26 @@ gboolean pick_probe(gpointer user) {
// What the `.ui` contributed, before anything is driven: the pages, the switcher and the buttons.
report_chooser_ui(c);
// `UDPSCOPE_PROBE_CHOOSER_TOUR=1` and `UDPSCOPE_PROBE_CHOOSER_HELP=1` drive the help menu's two
// stateful rows the way a click does, and then fall 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 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
// from that file and not from the dialog.
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_TOUR") != nullptr && c.help_tour != nullptr) {
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::fflush(stderr);
}
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_HELP") != nullptr && c.help_toggle != nullptr) {
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(c.help_toggle), TRUE);
const Settings back = load_settings();
std::fprintf(stderr, "[sources] chooser help: toggle=%d setting=%d file=%s\n",
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(c.help_toggle)) ? 1 : 0,
back.contextual_help ? 1 : 0, settings_path().c_str());
std::fflush(stderr);
}
// `UDPSCOPE_PROBE_CHOOSER_PREFS=1`: the preferences opened from *here* rather than from the app's
// menu -- the entry point that exists because this dialog runs before `App`. It is driven through
// the same fill-and-apply the app's probe uses, and reports what the run about to start will get,
@@ -906,15 +1023,24 @@ bool choose_source(Config& cfg) {
c.new_host = GTK_WIDGET(gtk_builder_get_object(c.ui, "new_host_button"));
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.help_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "help_menu_button"));
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.waves_open == nullptr || c.cancel_button == nullptr) {
c.waves_open == nullptr || c.cancel_button == nullptr || c.help_button == nullptr) {
std::fprintf(stderr, "[sources] chooser ui: source-chooser.ui is missing an id this code reads\n");
std::fflush(stderr);
return false;
}
adw_view_switcher_bar_set_stack(ADW_VIEW_SWITCHER_BAR(c.switcher), ADW_VIEW_STACK(c.page_stack));
// The help menu's rows are the window's own `menu_button()`/`menu_toggle()`, so its popover wants
// the same stylesheet the overflow menu is drawn with (`.udpscope-menu`, which trims a row to a
// menu row rather than Adwaita's 34 px button). The app installs it in `on_activate()`, which is
// *after* this dialog, so it has to be installed here first; `install_css()` is idempotent, so
// the later call is the no-op it reads as.
ui_impl::install_css();
build_chooser_help(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.waves_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c);
+9 -2
View File
@@ -5142,8 +5142,15 @@ void on_activate(GtkApplication* application, gpointer user) {
// A first run is the run whose settings file does not say the tour has been offered -- see
// `tour_autostart()`. Delayed rather than fired on the first tick: the window has to be up and
// laid out for the tour to have anything to point at, and a tour that opened on the frame the
// window appeared would be a spotlight on a widget with no allocation yet.
if (!app->settings.tour_seen) g_timeout_add(1500, tour_autostart, app);
// window appeared would be a spotlight on a widget with no allocation yet. The source chooser's
// own "Guided tour" entry arrives as `Config::tour_pending` (the request has to outlive the dialog
// that made it -- the tour is drawn on *this* window) and gets the same delay for the same reason,
// through its own callback so the two are told apart in the readings.
if (app->model->config().tour_pending) {
g_timeout_add(1500, tour_start_requested, app);
} else if (!app->settings.tour_seen) {
g_timeout_add(1500, tour_autostart, app);
}
// Bubble phase on purpose: a focused GtkEditable or GtkButton wins its own keys, so typing
// 'm' into the window entry does not toggle the measurements pane and Space does not
+11
View File
@@ -33,6 +33,17 @@
<property name="tooltip-text" translatable="yes">the event cache, relative time and the interface's theme — the same dialog as the main window's menu, and the only place relative time can still change this run</property>
</object>
</child>
<!-- The help menu: the same three affordances the window's Help view carries, because a
user who has not started yet is exactly the one who does not know how to start. Only
the button is here — its rows carry a handler and a setting, so they are code, the
same division every other row in this file follows. Beside `_Preferences`, because
both act on the *program* rather than on a source. -->
<child type="start">
<object class="GtkMenuButton" id="help_menu_button">
<property name="icon-name">help-about-symbolic</property>
<property name="tooltip-text" translatable="yes">the manual, a guided tour after start-up, and the contextual help</property>
</object>
</child>
<child type="end">
<object class="GtkButton" id="cancel_button">
<property name="label" translatable="yes">_Cancel</property>
+16
View File
@@ -255,6 +255,18 @@ struct App {
/// The first-run auto-start: set by the timer that fires it, and read by the probe and the dump.
/// The decision itself is `settings.tour_seen`, which is the whole of "this has happened before".
bool tour_auto_started = false;
/// The chooser's own "Guided tour" request (`Config::tour_pending`), recorded when its timer runs.
/// A separate flag from the auto-start because it is a *different* thing: the user asked for the
/// tour before the window existed, rather than never having seen it.
bool tour_requested = false;
/// The self-check's reading of the manual's launch (`UDPSCOPE_PROBE_HELP_MS`). The launch is
/// asynchronous and the viewer is another process, so the outcome arrives in a callback and the
/// probe prints it from a timer afterwards; `attempted` is set at the call, because a launch that
/// never called back is itself a reading.
bool dbg_help_launch_attempted = false;
bool dbg_help_launch_done = false;
bool dbg_help_launch_ok = false;
std::string dbg_help_launch_error;
/// The help layer's own dump readings, captured on the tick while the window is alive (nothing
/// here may be read in the dump -- see `docs/claude/verification.md`).
std::string dbg_context_help;
@@ -1743,6 +1755,10 @@ int tour_step_count(const App& app);
int tour_step_index(const App& app);
/// The first-run auto-start's callback, scheduled only when `settings.tour_seen` is false.
gboolean tour_autostart(gpointer user);
/// The chooser's "Guided tour" request (`Config::tour_pending`): the same delayed start, but it
/// sets `tour_requested` rather than `tour_auto_started`, so the probe and the dump can tell the two
/// apart. Scheduled only when the config carries the request.
gboolean tour_start_requested(gpointer user);
/// Captures the layer's own readings and the bounds of the surfaces the help's figures label, while
/// the window is alive -- the dump runs after it is destroyed and may not ask GTK anything.
void capture_help_state(App& app);
+37 -4
View File
@@ -4444,16 +4444,33 @@ UiShortcuts ui_shortcut_rows(const char* path) {
return out;
}
/// Prints the manual launch's outcome, which arrives in a callback (the launch is asynchronous and
/// the viewer is another process) -- so the line is written from a timer a moment later rather than
/// from `help_probe`. `done=0` is a launch that never called back, which is a reading of its own.
gboolean help_launch_report(gpointer user) {
App* a = static_cast<App*>(user);
const std::string installed = help_installed_path();
std::fprintf(stderr, "[help] launch attempted=%d done=%d ok=%d error='%s' installed=%s\n",
a->dbg_help_launch_attempted ? 1 : 0, a->dbg_help_launch_done ? 1 : 0,
a->dbg_help_launch_ok ? 1 : 0, a->dbg_help_launch_error.c_str(),
installed.empty() ? "(not found)" : installed.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
gboolean help_probe(gpointer user) {
App* a = static_cast<App*>(user);
const HelpReport report = help_self_check();
const std::string session =
layout_path(a->model->config().server, a->model->config().port);
// The manual is not opened here: it lives in another process (Yelp, through the `help:` URI)
// and neither it nor this box can draw a window for it, so there is nothing to open, read
// back or close. `help_self_check()` above is the assertion -- the set is on disk where the
// build says it is. The two AdwDialogs below are still built, read and closed for real.
// The manual is *launched* here and read no further: it opens in another process (Yelp, through
// the `help:` URI) and neither it nor this box can draw a window for it, so there is nothing to
// read back or close. `help_self_check()` above is the assertion on the *set* being on disk where
// the build says it is; the launch is the assertion on the *path*, and its outcome is reported by
// `help_launch_report()` once the launch's callback has run. The failure alert the callback shows
// is deliberately left up -- it is what a user sees, and the probe must not report a failure as a
// silence. The two AdwDialogs below are still built, read and closed for real.
show_about(GTK_WINDOW(a->window), session);
show_shortcuts(GTK_WINDOW(a->window));
// Read while it is open: the icon is the point of the dialog carrying one, and
@@ -4461,6 +4478,18 @@ gboolean help_probe(gpointer user) {
const std::string about_icon = about_icon_name();
close_help_windows();
// The manual's path: on a successful launch no window is left behind, so this cannot be observed
// any other way; `show_help()`'s reporter is the only hook that runs on that path.
a->dbg_help_launch_attempted = true;
a->dbg_help_launch_done = false;
a->dbg_help_launch_ok = false;
a->dbg_help_launch_error.clear();
show_help(GTK_WINDOW(a->window), [a](bool ok, const std::string& error) {
a->dbg_help_launch_done = true;
a->dbg_help_launch_ok = ok;
a->dbg_help_launch_error = error;
});
int accel_count = 0;
int gesture_count = 0;
(void)app_accels(&accel_count);
@@ -4479,6 +4508,10 @@ gboolean help_probe(gpointer user) {
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
about_icon.c_str());
std::fflush(stderr);
// The launch's outcome needs its callback to have run; a moment is enough for a local
// `g_app_info_launch_default_for_uri()`, and `done=0` in the line is how a launch that never
// called back reads.
g_timeout_add(600, help_launch_report, a);
return G_SOURCE_REMOVE;
}
+13 -2
View File
@@ -342,6 +342,16 @@ gboolean tour_autostart(gpointer user) {
return G_SOURCE_REMOVE;
}
gboolean tour_start_requested(gpointer user) {
App* app = static_cast<App*>(user);
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
// existed, and the reading has to say which of the two started it.
app->tour_requested = true;
start_tour(*app);
return G_SOURCE_REMOVE;
}
// --- the dump's readings ----------------------------------------------------
namespace {
@@ -381,10 +391,11 @@ void capture_help_state(App& app) {
char layer[512];
std::snprintf(layer, sizeof(layer),
"contextual=%s hints=%d ids={%s} icon=dialog-question-symbolic(%s); tour=%s steps=%d "
"step=%d auto=%d",
"step=%d auto=%d requested=%d",
app.settings.contextual_help ? "on" : "off", hints, ids.empty() ? "" : ids.c_str(),
icon_ok ? "ok" : "MISSING", app.tour_running ? "running" : "idle",
tour_step_count(app), tour_step_index(app), app.tour_auto_started ? 1 : 0);
tour_step_count(app), tour_step_index(app), app.tour_auto_started ? 1 : 0,
app.tour_requested ? 1 : 0);
app.dbg_context_help = layer;
app.dbg_tour_steps = tour_step_count(app);
app.dbg_tour_step = tour_step_index(app);
+7
View File
@@ -37,6 +37,13 @@ GtkBuilder* ui_load(App& app, const char* resource) {
/// the whole side panel. Scoped to `.udpscope-compact` so it cannot reach any other
/// button in the window.
void install_css() {
// Idempotent, because it is no longer installed from one place: the source chooser runs before the
// application (it draws a menu built from the same `menu_button()` rows) and has to have the
// stylesheet before it shows them, while `on_activate()` still installs it for every run. A second
// provider would be a second copy of the same rules on the display.
static bool installed = false;
if (installed) return;
installed = true;
static const char kCss[] =
"row.udpscope-compact { min-height: 0; padding: 1px 0; }\n"
// A popover menu row: Adwaita gives a button a 34 px minimum height and 8 px of padding,