From ccb496418228bfe606072451f05a99a0a85a99d5 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 4 Oct 2026 16:04:41 +0200 Subject: [PATCH] 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 --- CLAUDE.md | 48 ++++++++++++++++++++++++++++++ README.md | 66 ++++++++++++++++++++++++++++++++++++++++++ src/help/14-status.txt | 4 +-- 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index c1039df..c7caa47 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `: : ->` — 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 `` 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. diff --git a/README.md b/README.md index f4c6af7..6dc4247 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/help/14-status.txt b/src/help/14-status.txt index bee3e04..7cdbc5f 100644 --- a/src/help/14-status.txt +++ b/src/help/14-status.txt @@ -30,7 +30,7 @@ The bar is always visible under the plot. Click ⓘ for the detail popove Example -The bar reads ● live · 10.0 kS/s · 2 s window. Behind ⓘ: +The bar reads ● live · 10 kS/s · 2 s window. Behind ⓘ: Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3 channels, -Buffer 200.0 MB · 1677 s span · time x1 / stored 10.0 kS/s (wire 10.0 kS/s), +Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire 10 kS/s), Plot 1 3 traces · 195000 samples, Reconnects 0.