diff --git a/docs/superpowers/specs/2026-10-04-help-and-status-design.md b/docs/superpowers/specs/2026-10-04-help-and-status-design.md new file mode 100644 index 0000000..943d268 --- /dev/null +++ b/docs/superpowers/specs/2026-10-04-help-and-status-design.md @@ -0,0 +1,436 @@ + + + +# About, shortcuts, help, and a two-tier status bar + +udpscope has grown a lot of behaviour that is only written down in `CLAUDE.md` and `README.md`: +signals start disabled, a long window is decimated rather than refused, a measurement's rulers +are stepped because the pass is throttled, an export is the stored samples and not the LOD +buckets. None of it is reachable from the application. The plot's own keyboard and mouse +gestures are likewise undocumented in the app, and there is no way to tell which binary is +running. Meanwhile the status bar carries eleven fields on one line and is unreadable at a +glance. + +This adds four things: an About dialog, a shortcut overview, a paged help window, and a +two-tier status bar. The first three are windows that explain the application and share a +content source; the fourth is independent, and is here because it is the same kind of work on +the same surface. + +## Scope + +In: the three windows, the accelerator table behind two of them, app-level keyboard shortcuts, +one addition to `deps/plotter`'s `input::Controller` (axis-restricted keyboard zoom), and the +status bar's split into a glance line and a details popover. + +Out: a translated help (the app is English-only throughout), a search box over the help, a +preferences dialog, and any change to what the status line *measures* — the fields move, none +is added or deleted. + +## The About dialog + +Three facts, as asked: version, build timestamp, and the path of the persistent settings. + +**Version** is hand-maintained, `project(udpscope VERSION 0.1.0 LANGUAGES C CXX)`. Not +`git describe`: the repository has no tags, so `describe` degrades to a bare hash and presents +it as a version number. + +**Build timestamp and provenance** come from a generated translation unit. `src/build_info.hpp` +declares three constants and a CMake custom command writes `build_info.cpp` defining them on +**every** build: + +```cpp +namespace udpscope::build_info { +extern const char* const kVersion; ///< project(VERSION) +extern const char* const kCommit; ///< git describe --always --dirty, or "unknown" +extern const char* const kTimestamp; ///< "YYYY-MM-DD HH:MM UTC" +} +``` + +`__DATE__`/`__TIME__` are not used: they only move when that translation unit happens to +recompile, so a binary built today can honestly report last week. Regenerating unconditionally +costs one small compile and one relink per build, and it follows the pattern already in +`CMakeLists.txt:43-52`, where `udpscope_resources.c` is a generated source compiled into the +executable. The commit is included because it is the fact that actually answers "which binary +is this"; a timestamp alone does not. `kCommit` reads `unknown` when `git` is absent or the +tree is not a checkout — a build from a tarball must not fail for it. + +**The settings path** is `layout_store::config_dir()`, the same function the session files use, +followed by `layout_path(host, port)` for the source this run is connected to — the file a user +actually wants to find. Both as selectable labels, so they can be copied. + +The widget is a `GtkAboutDialog`. It is **not** deprecated in GTK 4.22 (it is +`GtkShortcutsWindow` that is), it gives the version field, a comments block and an MIT licence +section for free, and the two paths go in the comments block, which is the only part with room +for them. + +## Keyboard shortcuts + +### The plot's axis-restricted zoom, in `deps/plotter` + +`input::Controller` has `+`/`-` zooming **both** ranges about the plot centre, and no way to +zoom one axis from the keyboard. It gains that, with the modifier convention the box-zoom drags +already use: + +| keys | effect | +| --- | --- | +| `+` / `-` | zoom both ranges about the plot centre (unchanged) | +| `ctrl` + `+` / `-` | zoom the x axes only | +| `alt` + `+` / `-` | zoom the y axes only | + +**Not `shift`, and this is the one deliberate deviation from the approved design.** On the +common layouts `+` *is* shift+`=`, and `key_from()` (`adapters/gtk4/gtk_input.hpp:258`) folds +both onto `kKeyZoomIn` precisely for that reason — so a bare zoom-in keystroke already arrives +with `Mod::Shift` set, and `shift + (+/-)` cannot be distinguished from `+`. Making Shift mean +x-only would turn every plain zoom into an x-only zoom. Ctrl and Alt are unambiguous for keys, +and they are already the controller's drag convention (`ctrl`+drag = x only, `alt`+drag = y +only), so the keyboard and the mouse-drag rows of the overview read the same. The **wheel** +keeps its own `shift`=x / `ctrl`=y: that is upstream behaviour with upstream tests, and the +overview shows both honestly rather than hiding one. + +The change is smaller than it looks. `KeyEvent` already carries `mods` +(`include/plot/input/events.hpp:75`) and `PlotArea::on_key_pressed` already sets +`e.mods = mods_from(state)` (`adapters/gtk4/gtk_plot_area.cpp:862`), so no adapter change, no +new key constant, and no `key_from()` change: the whole change is the `if (zooms)` block of +`Controller::on_key`, which gains the same `has_mod(e.mods, Mod::Ctrl)` / `Mod::Alt` read the +drag branch makes at `src/input/controller.cpp:493-495` and skips the axis class the modifier +excluded. A comment on `kKeyZoomIn` records why Shift is not available here, so the next person +to reach for it finds the reason. + +Two tests in the controller's existing suite — `ctrl_plus_zooms_x_only_and_leaves_y` and +`alt_plus_zooms_y_only_and_leaves_x` — then the submodule commit and the pin move. + +### The application's keys + +| keys | action | +| --- | --- | +| `Space` | pause / resume | +| `a` | arm / disarm the trigger | +| `t` | trigger panel | +| `c` | cursors pane | +| `m` | measurements pane | +| `g` | math pane | +| `l` | layout editor | +| `s` | side panel | +| `Ctrl+E` | export the visible range | +| `F1` | help | +| `Ctrl+slash` | shortcut overview | +| `Ctrl+Q` | quit | + +Bare letters for the toggles, Ctrl for the things that touch files or windows. The plot's own +keys — `+`, `-`, `f`, the arrows, `Escape` — are not in this set and are not shadowed. + +One `GtkEventControllerKey` on the toplevel, in the **bubble** phase. That is what makes a +focused widget win its own keys: the plot area returns `FALSE` for everything `key_from()` maps +to `0`, which is every letter in this table and `Space` too, so they bubble up to this +controller. A focused `GtkEntry` or `GtkSpinButton` consumes its printable keys first, so +typing into the window entry or a math field is unaffected. A focused `GtkButton` consumes +`Space` to activate itself, so `Space` pauses only when focus is not on a button — ordinary GTK +behaviour, and the right outcome. The handler additionally refuses every app key while the +focused widget is a `GtkEditable`: belt and braces, and the property a probe can assert, since +a probe calls the handler directly and so bypasses the bubbling the first guarantee rests on. + +### The overview window + +Hand-rolled, not `GtkShortcutsWindow`: that widget was deprecated in GTK 4.18, this project +builds `-Wall -Wextra -Wpedantic` clean, and 4.22 is what is installed. + +A `GtkWindow` holding a `GtkScrolledWindow` over one two-column `GtkGrid` per section: the +accelerator on the left in a monospace face, the description on the right. Sections: +**Acquisition**, **Panes and editors**, **Files and windows**, **Plot keyboard**, **Plot +mouse**. The mouse section is the controller's gesture table in prose — `shift`/`ctrl`/`alt` + +drag for the three box zooms, drag and right-drag to pan, middle-drag on each label strip for +an axis zoom, the wheel with its two modifiers, horizontal wheel to pan x, double-click to +re-fit. + +## The accelerator table + +`src/shortcuts.hpp` holds **one** table, and both consumers read it: + +```cpp +enum class AppAction { PauseToggle, TriggerArm, TriggerPanel, CursorsPane, MeasuresPane, + MathPane, LayoutEditor, SidePanel, Export, Help, Shortcuts, Quit }; + +struct Accel { + unsigned keyval; ///< GDK keyval + unsigned mods; ///< GdkModifierType mask, 0 for a bare key + AppAction action; + const char* accel_text; ///< what the overview prints + const char* description; + const char* section; +}; +``` + +`ui.cpp`'s handler looks up `(keyval, mods)` in this table to get an `AppAction` and switches on +that; the overview renders `accel_text` and `description` from the same rows. A key that works +and a key that is documented therefore cannot drift apart — which is the failure this project +has hit before in another form, two comments in `build_toolbar()` disagreeing with each other +and one of them with reality. + +The mouse gestures are a second, display-only table in the same file — a gesture text and a +description, with no keyval, since nothing dispatches them — because they are rendered by the +same window and belong beside the keys they are compared against. + +## The help window + +A `GtkWindow` split in two: a `GtkListBox` of page titles on the left, a `GtkScrolledWindow` +holding one wrapping `GtkLabel` on the right. Selecting a row swaps the label's markup. Opened +by `F1` and from the overflow menu; **non-modal**, so it can sit beside the scope while a user +follows it. + +Each page is a file of **Pango markup** in a GResource (`src/help/*.txt`, bundled by +`src/help/udpscope-help.gresource.xml`, compiled by the same `glib-compile-resources` custom +command pattern the icons use). Pango markup rather than Markdown because `GtkLabel` renders it +natively — bold, italic and monospace spans with no parser to write — and a malformed span is +caught by `pango_parse_markup()` at load time rather than drawn wrong. A GResource rather than +files beside the binary for the reason already recorded for the icons: udpscope is run straight +out of the build tree and has no install step, so a search path would have to be located at +runtime and the failure mode of not locating it is silent. + +A page's title, resource path and section header are one entry in a static table in +`help_window.cpp`, so the order on screen is explicit in code rather than implied by file names. +The section headers are non-selectable rows in the `GtkListBox`. + +### Page shape + +Every page has the same four parts: + +1. what the feature does, +2. how to reach it, +3. **Worth knowing** — the surprising behaviour, distilled from the invariants in `CLAUDE.md` + and `README.md`, +4. **Example** — one worked case with real numbers taken from the probes, not round ones. + +The approved template, which every other page follows: + +``` +Time window +─────────── + +The window is how much time the plot shows. Set it from the header bar's +window button: type a number, pick a unit, or step a decade with the arrows. + +Every row shares one window. The trigger's event span follows it, and the +pre-trigger share is a share of *this* window. + +Worth knowing + A window too long to store at the wire rate is decimated, not refused. The + shared clock takes a power-of-two stride; each group of samples is + *averaged*, never strided, so a tone above the stored Nyquist is attenuated + instead of aliasing to a false low frequency. + + Moving the window across a decimation boundary rebuilds the buffer and drops + the history. + +Example + 1 MS/s, 200 MB of buffer. A 2 s window stores every sample (time x1). Widen + to 600 s and the stride becomes x16: 625 S/s stored, anti-aliased at + 312.5 Hz. +``` + +### The pages + +**Getting started** + +1. **Overview** — what each part of the window is: toolbar, side panel, plot rows, status bar. + The map the other pages refer back to. +2. **Choosing a source** — the startup chooser, why the list shows only sessions that name a + signal, what `×` deletes, the New host… form, and that a stored multicast session reconnects + to its group and interface. +3. **Signals and calibration** — signals start **disabled** and enabling is what starts + acquisition; a signal on no plot still gets a grey chip; calibration is applied at ingest, so + changing it rebuilds the buffer and drops history. +4. **Rows, layout and drag & drop** — drag a signal's chip from the side panel onto a row to + plot it; four slots per row; a signal's colour follows the *signal*, not the slot, so the same + signal on two rows is one colour; the layout editor's `+`/`×`, and that inserting a row + carries the existing rows' contents down with them rather than renumbering under the user. + +**Viewing** + +5. **Time window** — the template above. +6. **Navigating the plot** — the keyboard and mouse gestures in prose, plus what a *pinned* view + is: while an event is held, a capture is filling or the view is paused, a zoom in works but + the view cannot leave the data. +7. **Pause and play** — pausing freezes the *display*, not the producer: the ring keeps filling, + the view binds a snapshot, and the axis is pinned to the snapshot's own stamps, so a window + reaching back past the oldest retained sample shows a shorter span rather than an empty + margin. +8. **Trigger** — arm/disarm, edge and level, Normal vs Single, the pre-trigger share as a share + of *this* window, the four display states (live, capturing, held, waiting), that the trigger + sees the **stored** filtered signal, and that disarming mid-capture publishes what the capture + got to. + +**Analysis** + +9. **Cursors** — the shared vertical pair versus the per-row rulers, the four modes, what `mix` + reads, and that a marker past the newest sample reads `--`. +10. **Measurements** — the types, the threshold share, the on-plot marks, that a pass is + throttled to a cadence so a ruler is stepped, and that an invalid pass keeps its level marks + and drops its time marks. +11. **Math channels** — a graph evaluated as an ordered list, so a cycle is impossible by + construction; a derived channel *is* a channel; the filters run at the **stored** rate, so a + cutoff moves when the window changes the decimation; the output defaults to the last node. + +**Output and state** + +12. **Saving and export** — `.npy` and `.csv`, the visible range at the **stored** rate and never + the LOD buckets, the `time`/`stream_time` columns, and that the table is collected at click + time rather than when the file chooser returns. +13. **Sessions and settings** — where the files live, what a session persists (window, rows, + calibration, enabled signals, math graphs, measurements), when it is written, and why a + session whose streamer was down during a run is not overwritten. +14. **Status bar and diagnostics** — the glance line, what is behind ⓘ, and what `gaps`, + `reconnects` and the starvation notice actually mean. + +## The status bar + +### The glance line + +One row on the bar, four fields and no more: + +``` +● live · 10.0 kS/s · 2 s window ⓘ +``` + +The badge is unchanged — one glyph in three colours. The state field becomes one short word: +`live`, `paused`, `armed`, `capturing`, `event held`, `disarmed`. The long prose it carries +today (`trigger: EVENT HELD (Arm to re-arm, or 'Go live' for streaming)`) moves into the +popover. Trigger mode adds exactly one field, `· event 4.2 s ago`. + +Each field's width is bounded, for the reason `CLAUDE.md` already gives for `rate` collapsing +to one figure and for `format_elapsed()` stepping units: the status row sits directly above the +plot, so a field whose width tracks the stream relayouts the plot. That is why the state field +is a word from a fixed set rather than a sentence. + +The transient notice (`App::notice`) stays on the bar, prefixed to the glance line as it is +today: it is the one piece of text that must be seen without a click. + +### The details popover + +Behind an ⓘ `GtkMenuButton` at the end of the row, a `GtkPopover` over a `GtkGrid` of +label/value pairs: + +``` + 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) + Plot 1 3 traces · 195000 samples + Reconnects 0 +``` + +It is rewritten **only while it is visible**. A popover nobody opened costs nothing per frame, +and a visible one cannot relayout the plot because a popover is its own surface — which is the +property that makes the full-width detail safe to show at all. + +### One composition, two consumers + +`src/status_text.hpp` holds a `StatusSnapshot` of the fields the tick already computes, and two +formatters: + +```cpp +std::string format_glance(const StatusSnapshot&); ///< the bar +std::string format_detail(const StatusSnapshot&); ///< the popover, and the exit dump +``` + +`CLAUDE.md` asserts that `rate` and `last event … ago` are checked in the exit dump's +`dbg_mode_live`/`dbg_mode_triggered`, which today are `gtk_label_get_text(app->status_label)`. +The tick therefore still assembles the complete detail text every frame, into +`App::status_detail`; the dump reads **that** instead of the label, and the popover formats +from the same snapshot. The existing dump assertions keep working unchanged, and the detail is +not a second expression of fields the glance line also computes. + +## Files touched + +| File | Change | +| --- | --- | +| `src/build_info.hpp` | new: declares the three constants | +| *generated* `build_info.cpp` | new: defines them, rewritten every build | +| `src/shortcuts.hpp/.cpp` | new: the accelerator table and the mouse-gesture table | +| `src/help_window.hpp/.cpp` | new: `show_help_window()`, `show_shortcuts_window()`, `show_about_dialog()` | +| `src/help/*.txt`, `src/help/udpscope-help.gresource.xml` | new: the fourteen pages | +| `src/status_text.hpp/.cpp` | new: `StatusSnapshot`, `format_glance()`, `format_detail()` | +| `src/ui.cpp` | the key controller, three menu items, the ⓘ button and popover, the glance label, `App::status_detail`, the new probes | +| `CMakeLists.txt` | `VERSION`, the build-info command, the help resource, the new sources | +| `deps/plotter` | `Controller::on_key`'s zoom branch, two tests, the pin move | +| `CLAUDE.md`, `README.md` | the documentation this project requires for both | + +The three windows share one translation unit because they share one purpose, the GResource +access and the accelerator table. `src/ui.cpp` is already ~7400 lines and the largest file in +the project, so none of them goes in it. + +## Error handling + +| Case | Behaviour | +| --- | --- | +| A help page's resource is missing | the page's label reads a one-line "this page is missing" and the probe's `pages`/`parsed` counts disagree; nothing else is affected | +| A help page's markup is malformed | `pango_parse_markup()` fails, the page falls back to the same line, and `parsed` < `pages` | +| `git` absent or not a checkout | `kCommit` is `unknown`; the build succeeds | +| An app key pressed while a `GtkEditable` has focus | refused, the widget keeps it | +| A window already open | presented, not rebuilt — each of the three keeps one static instance pointer, and closes with `gtk_window_close()`, never `gtk_window_destroy()`, so the handler that clears the pointer runs (the trap the math dialog hit) | +| The ⓘ popover opened with no CONFIG yet | the detail fields read what they read today in that state; no special case | + +## Verification + +Probes on stderr in the style of the existing ones, since this project's whole regression net is +those probes and `CLAUDE.md` requires extending them rather than adding a harness. + +- **`UDPSCOPE_PROBE_HELP_MS`** — open all three windows, report, close them. Equal parts crash + test and content check, the way `UDPSCOPE_PROBE_MATH_MS` is: + + ``` + [help] pages=14 parsed=14 shortest='Overview' 412 B; shortcuts: app=12 mouse=9; + about version=0.1.0 commit=53815ad built=2026-10-04 14:22 UTC + settings=/home/martino/.config/udpscope; closed ok + ``` + + **`parsed` must equal `pages`**, which is what catches a malformed span as a number instead of + as mangled text on screen; **no page may be empty**, which catches a resource listed in the + `.xml` and never written; `settings=` must be a real path. `closed ok` with no critical is the + lifecycle assertion. + +- **`UDPSCOPE_PROBE_KEYS_MS`** — fire every row of the accelerator table at the handler directly + (the way the other probes emit `::clicked`) and report the state each one moved: + + ``` + [keys] a: armed 0->1 | space: paused 0->1 | c: cursors 0->1 | ... | + editable focused: 'm' refused=1 + ``` + + Every row must move exactly its own state and nothing else, and the last field must read + `refused=1`. + +- **`UDPSCOPE_PROBE_ZOOMKEYS_MS`** — the new plotter keys through the app, inside a **pinned** + view, so run it with the pause probe, which makes a stable pin + (`UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`): + + ``` + [zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1 + ``` + + Both `_held` must be 1 and both zooms must still happen: "a pinned view is navigable within + its data" is the invariant, and a modified zoom must not become its exception. + +- **The status bar**, in the exit dump: + + ``` + status bar: glance=[● live · 10.0 kS/s · 2 s window] max_glance_chars=38 + detail_fields=11 popover_rebuilds=0 + ``` + + **`max_glance_chars` must stay under a small constant** over the whole run — that is "layout + must not depend on content" read from the one label still on the bar. **`popover_rebuilds` + must be 0** on a run that never opened it. + +- **The plotter's own suite** — `xvfb-run -a deps/plotter/build/debug/tests/plot_tests`, `0 + failed`, with the two new controller tests. + +- **A full existing run must be unchanged.** The status refactor touches the line every other + probe's dump is read beside, so one `UDPSCOPE_EXIT_AFTER_MS` run with + `UDPSCOPE_ENABLE_ALL_MS` must still report `gaps=0`, `x-desync 0`, `0 collapsed, 0 staircase` + and every toolbar icon `(ok)`. + +`CLAUDE.md` gains these probes, plus invariants for the shared accelerator table, the +Shift-is-unexpressible reason behind ctrl/alt, and the one-composition-two-consumers status +rule. `README.md` gains the four features and records the deviation from the approved +`shift + (+/-)`.