Files
udpscope/docs/superpowers/specs/2026-10-04-help-and-status-design.md
Martino FerrariandClaude Opus 4.6 6b148f82a6 docs: design for the About/shortcuts/help windows and a two-tier status bar
Four features the user asked for together: an About dialog (version, build
timestamp, settings path), a shortcut overview covering the keyboard and the
mouse, a fourteen-page help window, and a status bar split into a short glance
line plus a details popover so the plot's own row stops being crowded.

Records one deviation from the approved design: keyboard zoom-x/zoom-y use
ctrl/alt rather than shift, because `+` *is* shift+`=` on the common layouts and
a bare zoom-in therefore already arrives with Shift set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 11:08:23 +02:00

437 lines
22 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
# 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 + (+/-)`.