# 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 + (+/-)`.