docs: help, shortcuts, About and the two-tier status bar

Adds the four new features to README.md (About dialog, shortcut overview
with Ctrl/Alt key table and deviation note, 14-page help window, two-tier
status bar), adds the four new probes and six new invariants to CLAUDE.md,
and fixes the `10.0 kS/s` rate-format error in src/help/14-status.txt
(format_rate() uses %.3g, which prints `10 kS/s`).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino FerrariandClaude Opus 4.6 committed 2026-10-04 16:04:41 +02:00
1 parent 8242812338
commit ccb4964182
3 files changed
+116 -2

No files matched your search

+48
View File
@@ -564,6 +564,10 @@ 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] pages=14 parsed=14 shortest='Overview' 1381 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=…; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. `settings=` must be a real path (the same one the About dialog shows). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check. Two of the three are `GtkWindow`s and close with `gtk_window_close()`; the shortcut overview is an `AdwDialog` and closes with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
- `[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1` (stderr, `UDPSCOPE_PROBE_ZOOMKEYS_MS`) — **both `_held` must be 1 and both zooms must still happen.** Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: `UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`. A `_held=0` is the modifier not reaching the plotter's controller; an unchanged range on the *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 detail_fields=11 popover_rebuilds=0` (exit dump) — **`max_glance_chars` must stay small** (under about 60) and **`popover_rebuilds` must be 0**. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set, the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the only field trigger mode adds is the elapsed time, which steps units rather than growing digits. `detail_fields` is the popover's label count, a literal, so adding a label without thinking about layout shows up here. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
The probes are the regression net — when changing UI or plot-host behaviour, extend
the probe/dump in `src/ui.cpp` rather than adding a separate harness.
@@ -1301,4 +1305,48 @@ far the largest file.
out — a source whose streamer was merely down during one run would silently vanish from the
only list it can be picked from, for ever. Keeping the old file loses nothing: a run that
configured nothing had nothing worth persisting.
- **The status bar is two tiers, and the dump reads the detail one.** `format_glance()` writes
the bar and `format_detail()` writes both the popover's prose and `App::status_detail`, which
is what `exit_probe_step3`/`exit_probe_step2` copy into `dbg_mode_live`/`dbg_mode_triggered`.
Those two used to read the *label*, and the label no longer carries `packets`, `gaps`,
`plot 1:` or the buffer figures — so a `gtk_label_get_text()` there would silently reduce
every documented `mode …:` assertion to four fields. The glance line's state field is **one
word from a fixed set** (`live`, `paused`, `armed`, `capturing`, `event held`, `disarmed`) for
the layout reason above; a seventh word, or a field whose width tracks the stream, breaks the
row the plot is laid out under.
- **The ⓘ popover's widgets are built once and only their text is set.** It is visible when the
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
the dump is the assertion.
- **The About dialog, the help window and the shortcut overview are opened by `show_about()`,
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** The help window
and the About dialog are `GtkWindow`s: they close with `gtk_window_close()`, which emits
`close-request` so the handler that nulls the file-scope pointer runs. `gtk_window_destroy()`
does not emit that signal and must not be used — the math dialog fell into exactly this trap,
and the pointer then points at a dead widget until the next attempt to open the same window.
The shortcut overview is an `AdwDialog` — it is **not** a `GtkWindow` — closed with
`adw_dialog_close()` and cleared on `::closed` (there is no `::close-request` on `AdwDialog`).
One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its close handler, is the shape that
prevents the double-open defect across all three.
- **`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).
`git` absent leaves `kCommit` at `"unknown"` and the build succeeds. The About dialog and the
`[help]` probe both read `kVersion`, `kCommit` and `kTimestamp` from it; `settings=` in the
probe reads `config_dir()`, which is the path the session file is written to.
- **The keyboard zoom's modifiers are not the wheel's.** `+`/`-` with **Ctrl** restricts to x
and with **Alt** to y, while the wheel keeps upstream's Shift = x / Ctrl = y convention. The
two cannot agree: Shift is unusable on the keyboard path because `key_from()` folds `'+'` and
`'='` onto `kKeyZoomIn` — `+` *is* Shift+`=` on common layouts, so a bare zoom-in already
arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special
case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y`
tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`.
- **A help page is one `.txt`, one `<file>` line in the GResource manifest, and one `kPages`
row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
Caps Lock is on.
- Every source file carries an SPDX MIT header; match it in new files.
+66
View File
@@ -17,6 +17,11 @@ cmake --build build
--multicast 239.1.2.3 --interface 10.0.0.9 # multicast
```
**System dependency: libadwaita ≥ 1.8** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`).
It is used for exactly one widget — the shortcut overview is an `AdwShortcutsDialog` — because
GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and `AdwShortcutsDialog` is its
replacement.
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb`
(size of the sample buffer, default 200), `--max-window` (largest time
window the toolbar offers, default 600), `--holdoff` (default trigger hold-off),
@@ -1209,6 +1214,67 @@ on the live ring too. So exporting during a capture writes the partly filled win
is on screen (`time` from `-pre` up to wherever the post side has reached), not the
previous event.
**The About dialog** (overflow menu) reports the running binary's version, the git commit it was
built from, the build timestamp, the settings directory and this session's layout file. Version,
commit and timestamp come from `src/build_info.cpp`, generated by CMake at configure time and
regenerated on every build — so the commit and the timestamp always belong to the binary that is
running, and `git` absent leaves the commit as `"unknown"` without breaking the build.
**The shortcut overview** (toolbar **?** button, or Ctrl+?) lists all keyboard and mouse controls
in two groups — application actions and plot controls — and is an `AdwShortcutsDialog` rather
than GTK4's deprecated `GtkShortcutsWindow`. libadwaita ≥ 1.8 is therefore a hard dependency;
`CLAUDE.md` records the one consequence worth knowing before editing (the stylesheet it installs
reaches every widget). The full key table:
| key | action |
|---|---|
| `a` | arm / disarm the trigger |
| `Space` | pause / resume |
| `t` | show / hide the trigger panel |
| `c` | show / hide cursors |
| `m` | show / hide measurements |
| `g` | show / hide math channels |
| `l` | show / hide the layout editor |
| `s` | show / hide the side panel |
| `f` | fit to data |
| `e` | export visible range |
| `+` / `-` | zoom in / out (both axes) |
| `Ctrl+` / `Ctrl-` | zoom in / out (x axis only) |
| `Alt+` / `Alt-` | zoom in / out (y axis only) |
The wheel uses Shift = x / Ctrl = y, while the keyboard uses Ctrl = x / Alt = y. The two
cannot agree: `+` *is* Shift+`=` on common layouts, so a bare zoom-in already arrives with
`Mod::Shift` set, and using Shift for axis restriction would make x-only zoom unreachable from
the keyboard. This is a **deliberate deviation from the wheel's convention**, documented in the
shortcut overview itself.
The six "Plot mouse" rows (wheel, drag, right-click) carry no accelerator key — GTK's shortcut
syntax has no member for pointer gestures — and put the prose in the `subtitle` field instead,
which `AdwShortcutsItem` renders as free text.
**The 14-page help window** (overflow menu, F1, or the toolbar **?** button long-press) is a
scrollable text view that opens on the page nearest to what the user was doing: the help pages
are compiled into the binary as a GResource (`src/help/udpscope_help.gresource.xml`,
`--c-name=udpscope_help`) for the same reason the icons are — udpscope is run straight out of the
build tree with no install step, so a search path would have to be found at runtime and a missing
file fails silently. The pages are Pango markup (`.txt` bodies), validated at load; a page that
fails validation renders a one-line stand-in so the window always opens rather than crashing.
`UDPSCOPE_PROBE_HELP_MS` verifies that `parsed == pages` and that the `closed ok` line carries
no `Gtk-CRITICAL`.
**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), the stored rate and the window. Everything else —
packet and frame counts, gaps, buffer size and span, per-plot trace counts — is behind the ⓘ
button at the bar's right-hand end, opening a popover. The two moved there because a bar that
carried all of them was too wide to read while watching a signal, and because the bar sits
directly above the plot: a field whose width tracked the stream would relayout the plot on every
update. The rate field uses `%.3g` (`10 kS/s`, never `10.0 kS/s`), and the elapsed-time field
steps to a coarser unit rather than growing digits.
The exit dump still prints the full detail string (`mode with trigger on:` / `mode after
go-live:`) because those strings come from `format_detail()` — the same path the popover
reads — not from the label, which no longer carries those fields.
## Review findings not (yet) implemented
These are known deviations, found by a spec/code review, that are deliberate for
+2 -2
View File
@@ -30,7 +30,7 @@ The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popove
<b>Example</b>
The bar reads <tt>● live · 10.0 kS/s · 2 s window</tt>. Behind ⓘ:
The bar reads <tt>● live · 10 kS/s · 2 s window</tt>. Behind ⓘ:
<tt>Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3 channels</tt>,
<tt>Buffer 200.0 MB · 1677 s span · time x1 / stored 10.0 kS/s (wire 10.0 kS/s)</tt>,
<tt>Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire 10 kS/s)</tt>,
<tt>Plot 1 3 traces · 195000 samples</tt>, <tt>Reconnects 0</tt>.