Files
udpscope/docs/superpowers/specs/2026-10-04-help-and-status-design.md
T
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

22 KiB
Raw Blame History

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:

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:

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

  1. Time window — the template above.
  2. 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.
  3. 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.
  4. 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

  1. 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 --.
  2. 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.
  3. 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

  1. 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.
  2. 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.
  3. 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:

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