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>
22 KiB
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:
- what the feature does,
- how to reach it,
- Worth knowing — the surprising behaviour, distilled from the invariants in
CLAUDE.mdandREADME.md, - 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
- Overview — what each part of the window is: toolbar, side panel, plot rows, status bar. The map the other pages refer back to.
- 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. - 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.
- 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
- Time window — the template above.
- 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.
- 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.
- 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
- Cursors — the shared vertical pair versus the per-row rulers, the four modes, what
mixreads, and that a marker past the newest sample reads--. - 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.
- 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
- Saving and export —
.npyand.csv, the visible range at the stored rate and never the LOD buckets, thetime/stream_timecolumns, and that the table is collected at click time rather than when the file chooser returns. - 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.
- Status bar and diagnostics — the glance line, what is behind ⓘ, and what
gaps,reconnectsand 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 wayUDPSCOPE_PROBE_MATH_MSis:[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 okparsedmust equalpages, 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.xmland never written;settings=must be a real path.closed okwith 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=1Every 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=1Both
_heldmust 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=0max_glance_charsmust 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_rebuildsmust 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_MSrun withUDPSCOPE_ENABLE_ALL_MSmust still reportgaps=0,x-desync 0,0 collapsed, 0 staircaseand 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 + (+/-).