Files
udpscope/docs/superpowers/plans/2026-10-04-help-and-status.md
T
Martino FerrariandClaude Opus 4.6 82f91d00c0 docs: implementation plan for the help/shortcuts/status work
Eleven tasks over the approved spec: build info and About, the plotter's
ctrl/alt axis-restricted zoom, 14 help pages, the accelerator table and
overview window, the app key controller, and the two-tier status bar.

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

127 KiB
Raw Blame History

Help, Shortcuts and Status Bar Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Give udpscope an About dialog, a keyboard/mouse shortcut overview, a 14-page help window, and a two-tier status bar whose on-bar line is one short glance and whose detail lives in a popover.

Architecture: Four new translation units plus a generated one. src/build_info.cpp is written by CMake at configure and build time and read through src/build_info.hpp. src/shortcuts.cpp owns one static Accel table (app actions) and one static mouse-gesture table; src/ui.cpp installs a single bubble-phase GtkEventControllerKey that looks a key up in that table and calls run_app_action(). src/help_window.cpp owns all three windows (About, overview, help) and loads page bodies out of a second GResource bundle compiled from src/help/*.txt. src/status_text.cpp owns format_glance() and format_detail() over a plain StatusSnapshot struct that ui.cpp fills each second; the bar shows the glance string, an ⓘ button shows a popover grid built from the snapshot, and the exit dump keeps printing the detail string so the existing mode with trigger on: assertions still read what they always read. The keyboard zoom modifiers (ctrl = x only, alt = y only) go into deps/plotter's input::Controller with its own upstream tests, because that is where the wheel's modifiers already live.

Tech Stack: C++20, GTK4 (≥ 4.10 API surface; GtkAboutDialog, GtkEventControllerKey, GtkPopover, GtkGrid), Pango markup, GResource via glib-compile-resources, CMake ≥ 3.24, deps/plotter (submodule, GTK4 adapter only), xvfb-run + tools/mock_udpstreamer.py for verification.

Global Constraints

Copied verbatim from docs/superpowers/specs/2026-10-04-help-and-status-design.md. Every task's requirements implicitly include this section.

  • Spec file: docs/superpowers/specs/2026-10-04-help-and-status-design.md is the source of truth for every interface, string and probe format below.
  • SPDX: every new source file carries // SPDX-License-Identifier: MIT as its first line, matching the existing files.
  • Warnings: the project builds with -Wall -Wextra -Wpedantic and must stay warning-free in src/. GtkShortcutsWindow is deprecated as of GTK 4.18 and must not be used — the overview is hand-rolled. GtkAboutDialog is not deprecated and is used.
  • Closing windows: every window is closed with gtk_window_close(), never gtk_window_destroy() — a destroy does not emit close-request, so the handler that nulls the App pointer never runs and the next open presents a dead widget.
  • Keyboard phase: the app's key controller runs in the bubble phase (GTK_PHASE_BUBBLE) so a focused GtkEditable or GtkButton wins its own keys. An app accel is refused outright while a GtkEditable has focus.
  • App key table (exact): Space pause/resume · a arm/disarm · t trigger panel · c cursors pane · m measurements pane · g math pane · l layout editor · s side panel · Ctrl+E export · F1 help · Ctrl+slash shortcut overview · Ctrl+Q quit. Bare letters are toggles; Ctrl is for files and windows. The plot's own keys (+, -, f, arrows, Escape) are not shadowed.
  • Plot keyboard modifiers: +/- zoom both axes; Ctrl restricts to x, Alt restricts to y. This is a deliberate deviation from the brainstormed Shift, because key_from() folds '+' and '=' onto one keyval and + is shift+= on common layouts, so a bare zoom-in already arrives with Mod::Shift set. The wheel keeps its existing upstream convention: shift = x, ctrl = y.
  • Help pages: 14, in four groups — Getting started (1 Overview, 2 Choosing a source, 3 Signals and calibration, 4 Rows, layout and drag & drop), Viewing (5 Time window, 6 Navigating the plot, 7 Pause and play, 8 Trigger), Analysis (9 Cursors, 10 Measurements, 11 Math channels), Output and state (12 Saving and export, 13 Sessions and settings, 14 Status bar and diagnostics). Every page has four parts: what it does, how to reach it, Worth knowing, Example. Examples use real numbers from the probes, never round invented ones.
  • Glance line (exact shape): ● live · 10.0 kS/s · 2 s window with an ⓘ button at the far right. The state field is one word from a fixed set: live, paused, armed, capturing, event held, disarmed. Trigger mode adds exactly one field: · event 4.2 s ago. App::notice stays prefixed to the line as it is today.
  • Layout must not depend on content. The status row sits directly above the plot, so no on-bar field's width may track the stream. The state field is a word from the fixed set above, rate keeps its collapse to one figure or a range, and format_elapsed() keeps stepping to a coarser unit.
  • Popover grid (exact rows): Stream, Buffer, Plot 1, Reconnects, with the field text as in the spec's sample. The popover's widgets are built once and only their labels set — popover_rebuilds in the dump must read 0.
  • Dump compatibility: format_detail() reproduces today's status string field for field (rate …, | last event … ago, packets/frames/gaps, the signal and channel counts, plot 1:, the buffer figures). CLAUDE.md asserts those fields are read out of dbg_mode_live/dbg_mode_triggered, so both must now be read from App::status_detail rather than from the label.
  • Error handling: a missing help resource renders a one-line "this page is missing" body and is excluded from parsed; markup pango_parse_markup() rejects does the same. git absent → kCommit is "unknown" and the build succeeds. A window already open is gtk_window_present()ed. The ⓘ popover with no CONFIG has no special case.
  • Probes: UDPSCOPE_PROBE_HELP_MS, UDPSCOPE_PROBE_KEYS_MS, UDPSCOPE_PROBE_ZOOMKEYS_MS, plus a status bar: line in the exit dump. Formats are given verbatim in the tasks that add them.
  • Verification discipline: one xvfb-run -a at a time (two share a display and the second silently gets no GL context), every probe run passes --server (a run without it reaches the source chooser and hangs), and each run's mock is killed afterwards with pkill -f 'mock_udpstream[e]r' and confirmed with pgrep -f 'mock_udpstream[e]r'.
  • Docs: the final task updates both CLAUDE.md (the four probes, the new invariants) and README.md (the features and the ctrl/alt deviation). A user request overrides SPECS.md; the deviation is documented in both files.
  • Submodule: deps/plotter changes are committed in the submodule and its pin moved in the superproject. Do not push either.

File Structure

File Status Responsibility
cmake/write_build_info.cmake create CMake script: resolve version/commit/timestamp, write build_info.cpp. Run at configure time and on every build.
src/build_info.hpp create Three extern const char* const declarations in namespace udpscope::build_info.
${CMAKE_BINARY_DIR}/build_info.cpp generated Their definitions. Never committed.
src/shortcuts.hpp create AppAction, Accel, the two table accessors, find_accel().
src/shortcuts.cpp create The one app accel table and the one mouse-gesture table. Data only.
src/help_window.hpp create show_about(), show_shortcuts(), show_help(), plus the probe's report function.
src/help_window.cpp create All three windows, the page registry, resource loading and markup validation.
src/help/*.txt create 14 page bodies, Pango markup, one file per page.
src/help/udpscope-help.gresource.xml create Bundles them under /org/udpscope/help.
src/status_text.hpp create StatusSnapshot, format_glance(), format_detail(), and the two formatters moved out of ui.cpp (format_rate, format_elapsed).
src/status_text.cpp create Their definitions. No GTK.
src/layout_store.hpp modify Export config_dir() (the About dialog needs the settings path).
src/layout_store.cpp modify Move config_dir() out of the anonymous namespace.
src/ui.cpp modify Menu items, the key controller, run_app_action(), the two-tier status row, the three probes, the dump line.
CMakeLists.txt modify project(VERSION), the build-info target, the help GResource, the four new sources.
deps/plotter/src/input/controller.cpp modify Ctrl/Alt axis restriction on kKeyZoomIn/kKeyZoomOut.
deps/plotter/tests/test_controller.cpp modify Two tests for it.
CLAUDE.md, README.md modify Probes, invariants, features, the deviation.

src/help_window.cpp holds three windows rather than one file each because they are one feature with one lifecycle rule (close with gtk_window_close(), null the pointer in close-request) and no shared state beyond that; splitting them would triplicate the rule. src/status_text.cpp is separate from ui.cpp precisely because it has no GTK dependency and is the one part of the status bar that can be reasoned about as pure text.


Task 1: Build info and the About dialog

Files:

  • Create: cmake/write_build_info.cmake
  • Create: src/build_info.hpp
  • Create: src/help_window.hpp
  • Create: src/help_window.cpp
  • Modify: CMakeLists.txt:2 (add VERSION), after :52 (build-info target), :55-70 (sources)
  • Modify: src/layout_store.hpp:84 (declare config_dir())
  • Modify: src/layout_store.cpp:34-40 and :115 (move config_dir())
  • Modify: src/ui.cpp — build_toolbar()'s menu (around :5580), and the About menu item's handler

Interfaces:

  • Consumes: nothing from earlier tasks.

  • Produces:

    • namespace udpscope { std::string config_dir(); } in src/layout_store.hpp
    • namespace udpscope::build_info { extern const char* const kVersion; extern const char* const kCommit; extern const char* const kTimestamp; }
    • namespace udpscope { void show_about(GtkWindow* parent, const std::string& session_path); } — Tasks 4 and 8 add show_help() and show_shortcuts() to the same header.
  • Step 1: Write the CMake build-info script

Create cmake/write_build_info.cmake:

# SPDX-License-Identifier: MIT
#
# Writes ${OUT} with the three strings src/build_info.hpp declares. Run both at
# configure time (so the file exists when add_executable names it) and on every
# build (so the commit and the timestamp are the ones that produced the binary).
#
# Expects: SRC (the source tree), OUT (the .cpp to write), VERSION.

set(COMMIT "unknown")
find_package(Git QUIET)
if(GIT_FOUND AND EXISTS "${SRC}/.git")
  execute_process(
    COMMAND ${GIT_EXECUTABLE} describe --always --dirty
    WORKING_DIRECTORY "${SRC}"
    OUTPUT_VARIABLE GIT_OUT
    OUTPUT_STRIP_TRAILING_WHITESPACE
    ERROR_QUIET
    RESULT_VARIABLE GIT_RC)
  if(GIT_RC EQUAL 0 AND NOT GIT_OUT STREQUAL "")
    set(COMMIT "${GIT_OUT}")
  endif()
endif()

string(TIMESTAMP STAMP "%Y-%m-%d %H:%M UTC" UTC)

file(WRITE "${OUT}"
"// SPDX-License-Identifier: MIT
// Generated by cmake/write_build_info.cmake. Do not edit, do not commit.
#include \"build_info.hpp\"

namespace udpscope::build_info {

const char* const kVersion = \"${VERSION}\";
const char* const kCommit = \"${COMMIT}\";
const char* const kTimestamp = \"${STAMP}\";

}  // namespace udpscope::build_info
")

EXISTS "${SRC}/.git" is true for a worktree's .git file as well as a directory, so this works in a git worktree. The write is unconditional: the file is a dozen bytes and rewriting it costs one recompile of one tiny TU per build, which is the cost the spec accepted in exchange for a timestamp that is never stale.

  • Step 2: Write the build-info header

Create src/build_info.hpp:

// SPDX-License-Identifier: MIT
#pragma once

namespace udpscope::build_info {

/// The CMake project version, e.g. "0.1.0".
extern const char* const kVersion;
/// `git describe --always --dirty` at build time, or "unknown" if git was not available.
extern const char* const kCommit;
/// When the binary was configured/built, as "YYYY-MM-DD HH:MM UTC".
extern const char* const kTimestamp;

}  // namespace udpscope::build_info
  • Step 3: Wire the script into CMake

In CMakeLists.txt:2, replace:

project(udpscope LANGUAGES C CXX)

with:

project(udpscope VERSION 0.1.0 LANGUAGES C CXX)

Then, immediately after the icon GResource add_custom_command block (after line 52, before the # --- the application --- comment), insert:

# --- build info: version, commit and timestamp compiled into the binary -------
# Written at configure time so add_executable can name the file, and again on every
# build so the commit and the timestamp belong to the binary that is running rather
# than to whenever the tree was last configured.
set(UDPSCOPE_BUILD_INFO ${CMAKE_CURRENT_BINARY_DIR}/build_info.cpp)
set(UDPSCOPE_BUILD_INFO_ARGS
    -DSRC=${CMAKE_CURRENT_SOURCE_DIR}
    -DOUT=${UDPSCOPE_BUILD_INFO}
    -DVERSION=${PROJECT_VERSION}
    -P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/write_build_info.cmake)
execute_process(COMMAND ${CMAKE_COMMAND} ${UDPSCOPE_BUILD_INFO_ARGS})
add_custom_target(udpscope_build_info
  BYPRODUCTS ${UDPSCOPE_BUILD_INFO}
  COMMAND ${CMAKE_COMMAND} ${UDPSCOPE_BUILD_INFO_ARGS}
  COMMENT "Writing udpscope build info"
  VERBATIM)
set_source_files_properties(${UDPSCOPE_BUILD_INFO} PROPERTIES GENERATED TRUE)

In the add_executable(udpscope ...) list, add ${UDPSCOPE_BUILD_INFO} and src/help_window.cpp — put the generated file next to the generated resources at the top, and src/help_window.cpp before src/ui.cpp:

add_executable(udpscope
  ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
  ${UDPSCOPE_BUILD_INFO}
  src/main.cpp
  src/config.cpp
  src/channel.cpp
  src/model.cpp
  src/trigger.cpp
  src/acquisition.cpp
  src/plot_host.cpp
  src/export.cpp
  src/layout_store.cpp
  src/source_dialog.cpp
  src/measure.cpp
  src/math_channel.cpp
  src/help_window.cpp
  src/ui.cpp
)

And after the target_link_libraries line (:73), add:

add_dependencies(udpscope udpscope_build_info)
  • Step 4: Export config_dir() from layout_store

In src/layout_store.hpp, immediately before the doc comment of layout_path() (line 84), insert:

/// The directory session files live in: $XDG_CONFIG_HOME/udpscope, else
/// ~/.config/udpscope, else /tmp/udpscope-config. Not created by this call.
std::string config_dir();

In src/layout_store.cpp, delete lines 34-40 (the config_dir() definition inside the anonymous namespace) and re-insert the same body immediately after the } // namespace that closes the anonymous namespace at line 115, before layout_path():

std::string config_dir() {
  const char* xdg = std::getenv("XDG_CONFIG_HOME");
  if (xdg != nullptr && xdg[0] != '\0') return std::string(xdg) + "/udpscope";
  const char* home = std::getenv("HOME");
  if (home == nullptr || home[0] == '\0') return "/tmp/udpscope-config";
  return std::string(home) + "/.config/udpscope";
}

Note these functions live directly in namespace udpscope — there is no layout_store namespace, so callers write config_dir(), not layout_store::config_dir().

  • Step 5: Write the help_window header

Create src/help_window.hpp:

// SPDX-License-Identifier: MIT
#pragma once

#include <gtk/gtk.h>

#include <string>

namespace udpscope {

/// The About dialog: version, commit, build timestamp and where the session files live.
/// Modal on `parent`. `session_path` is this run's own session file.
void show_about(GtkWindow* parent, const std::string& session_path);

}  // namespace udpscope
  • Step 6: Write the About dialog

Create src/help_window.cpp:

// SPDX-License-Identifier: MIT
#include "help_window.hpp"

#include <cstdio>

#include "build_info.hpp"
#include "layout_store.hpp"

namespace udpscope {
namespace {

/// Nulled by the `close-request` handler so a second open cannot present a dead widget.
/// One pointer per window, file-scope because there is one application.
GtkWidget* g_about = nullptr;

gboolean on_window_close(GtkWindow* window, gpointer slot) {
  (void)window;
  *static_cast<GtkWidget**>(slot) = nullptr;
  return FALSE;  // let the default handler destroy it
}

}  // namespace

void show_about(GtkWindow* parent, const std::string& session_path) {
  if (g_about != nullptr) {
    gtk_window_present(GTK_WINDOW(g_about));
    return;
  }

  // GtkAboutDialog is not deprecated in GTK 4 (unlike GtkShortcutsWindow), so the
  // stock dialog is used here and the overview is hand-rolled.
  GtkWidget* dialog = gtk_about_dialog_new();
  g_about = dialog;
  GtkAboutDialog* about = GTK_ABOUT_DIALOG(dialog);
  gtk_about_dialog_set_program_name(about, "udpscope");
  gtk_about_dialog_set_version(about, build_info::kVersion);
  gtk_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources.");
  gtk_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11);

  char details[1024];
  std::snprintf(details, sizeof(details),
                "Commit: %s\nBuilt: %s\nSettings: %s\nSession file: %s",
                build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
                session_path.c_str());
  gtk_about_dialog_set_system_information(about, details);

  gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
  gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
  g_signal_connect(dialog, "close-request", G_CALLBACK(on_window_close), &g_about);
  gtk_window_present(GTK_WINDOW(dialog));
}

}  // namespace udpscope
  • Step 7: Add the About menu item

In src/ui.cpp, add the include beside the other project headers at the top of the file:

#include "help_window.hpp"

In build_toolbar(), after the app.signal_toggle ("Signal list") button is appended to menu_box, append a second separator and the About item:

  gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));

  GtkWidget* about_item = gtk_button_new_with_label("About udpscope");
  gtk_widget_set_tooltip_text(about_item, "version, build timestamp and settings paths");
  g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
  gtk_box_append(GTK_BOX(menu_box), about_item);

That is exactly the shape of its neighbours: export_btn (src/ui.cpp:5531-5536) is a plain gtk_button_new_with_label with a tooltip, appended straight to menu_box — it sets neither gtk_button_set_has_frame() nor gtk_widget_set_halign(), so neither does this item.

Define the handler in the anonymous namespace above build_toolbar():

void on_about_clicked(GtkButton*, gpointer user) {
  App* app = static_cast<App*>(user);
  if (app->menu_button != nullptr) {
    GtkWidget* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
    if (popover != nullptr) gtk_popover_popdown(GTK_POPOVER(popover));
  }
  show_about(GTK_WINDOW(app->window),
             layout_path(app->model->config().server, app->model->config().port));
}

Check how on_export_clicked reaches the window and the model (app->window, app->model) and use the same member names; if the menu's popover is dismissed automatically by the existing items, drop the popdown block rather than adding a second mechanism.

  • Step 8: Build

Run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo && cmake --build build Expected: configures and builds with no warnings from anything under src/. build/build_info.cpp exists and its kCommit is a real hash.

  • Step 9: Verify the generated file and the dialog

Run: cat build/build_info.cpp Expected: kVersion = "0.1.0", kCommit a git describe string, kTimestamp today's date with UTC.

Then confirm the build info reaches the binary and the About dialog opens and closes without a critical. Start the mock, run the app under xvfb with the existing exit probe, and grep the output:

python3 tools/mock_udpstreamer.py --duration 20 &
sleep 1
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 \
  ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | tail -40
pkill -f 'mock_udpstream[e]r'; pgrep -f 'mock_udpstream[e]r' || echo "mock stopped"

Expected: the usual exit dump with gaps 0, no Gtk-CRITICAL, and mock stopped. The dialog itself is exercised by Task 4's probe, which reports the About strings; this run is the "nothing regressed" check.

  • Step 10: Commit
git add cmake/write_build_info.cmake src/build_info.hpp src/help_window.hpp \
        src/help_window.cpp src/layout_store.hpp src/layout_store.cpp \
        src/ui.cpp CMakeLists.txt
git commit -m "feat: About dialog with version, commit, build time and settings path"

Task 2: Ctrl/Alt axis restriction for keyboard zoom (deps/plotter)

Files:

  • Modify: deps/plotter/src/input/controller.cpp — the if (zooms) block in Controller::on_key (around :293)
  • Modify: deps/plotter/tests/test_controller.cpp — two new tests
  • Modify: the superproject's submodule pin

Interfaces:

  • Consumes: plot::input::Mod, has_mod(), KeyEvent::mods — all already exist (deps/plotter/include/plot/input/events.hpp:15,19,73-77). The GTK4 adapter already fills e.mods (gtk_plot_area.cpp:862), so no adapter change is needed.

  • Produces: +/- with Ctrl zooms x only, with Alt zooms y only, bare zooms both. Task 3's probe asserts it through the app.

  • Step 1: Write the failing tests

In deps/plotter/tests/test_controller.cpp, after the existing key_minus_zooms_out test, add:

PLOT_TEST(ctrl_plus_zooms_only_x) {
  Fixture f;
  f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
  constexpr plot::u32 kAltMod = static_cast<plot::u32>(plot::input::Mod::Alt);
  (void)kAltMod;
  const f64 x_before = f.plot.x_range(f.ax).span();
  const f64 y_before = f.plot.y_range(f.ay).span();
  f.controller.on_key({plot::input::kKeyZoomIn, kCtrl, true});
  PLOT_CHECK(f.plot.x_range(f.ax).span() < x_before);
  PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), y_before, 1e-12);
}

PLOT_TEST(alt_plus_zooms_only_y) {
  Fixture f;
  f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
  constexpr plot::u32 kAlt = static_cast<plot::u32>(plot::input::Mod::Alt);
  const f64 x_before = f.plot.x_range(f.ax).span();
  const f64 y_before = f.plot.y_range(f.ay).span();
  f.controller.on_key({plot::input::kKeyZoomIn, kAlt, true});
  PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), x_before, 1e-12);
  PLOT_CHECK(f.plot.y_range(f.ay).span() < y_before);
}

kCtrl is already a file-scope constant in that file; kAlt is not, hence the local constexpr. Delete the unused kAltMod/(void) pair from the first test if the file's style prefers it — it is there only to make the two tests read alike, and -Wunused would otherwise fire. Match Fixture's existing add_y_axis call shape exactly: read how another test in the file adds f.ay and copy it rather than trusting the argument list above.

  • Step 2: Run them and watch them fail

Run: cmake --build deps/plotter/build/debug && xvfb-run -a deps/plotter/build/debug/tests/plot_tests 2>&1 | tail -20 Expected: alt_plus_zooms_only_y fails (the x span shrank too) and ctrl_plus_zooms_only_x fails (the y span shrank too). If deps/plotter/build/debug does not exist, configure it first: cmake -S deps/plotter -B deps/plotter/build/debug -G Ninja -DCMAKE_BUILD_TYPE=Debug -DPLOT_BUILD_TESTS=ON

  • Step 3: Implement the restriction

In deps/plotter/src/input/controller.cpp, inside Controller::on_key's if (zooms) block, after notches and factor are computed and before the axis loops, add:

    // Ctrl restricts the zoom to x, Alt to y; bare +/- zooms both. (Shift cannot be
    // used: '+' is shift+'=' on common layouts, so key_from() folds them together and
    // a bare zoom-in already arrives with Mod::Shift set.) Ctrl+Alt zooms neither,
    // which is harmless and needs no special case.
    const bool only_x = has_mod(e.mods, Mod::Ctrl);
    const bool only_y = has_mod(e.mods, Mod::Alt);

then wrap the existing x-axis loop in if (!only_y) { ... } and the existing y-axis loop in if (!only_x) { ... }. Change nothing else — the midpoint, the notches/factor arithmetic and the key-up early return stay exactly as they are.

  • Step 4: Run the whole suite

Run: cmake --build deps/plotter/build/debug && xvfb-run -a deps/plotter/build/debug/tests/plot_tests 2>&1 | tail -20 Expected: 0 failed, including the pre-existing key_plus_zooms_in_about_the_centre and key_minus_zooms_out (which pass 0 for mods and so must keep zooming both axes).

  • Step 5: Commit in the submodule and move the pin
git -C deps/plotter add src/input/controller.cpp tests/test_controller.cpp
git -C deps/plotter commit -m "feat(input): Ctrl restricts keyboard zoom to x, Alt to y"
git add deps/plotter
git commit -m "chore: bump deps/plotter for ctrl/alt keyboard zoom"

Do not push either repository.


Task 3: The zoomkeys probe

Files:

  • Modify: src/ui.cpp — a new probe function in the anonymous namespace, and its registration beside the other g_getenv probe blocks (around :7190-7245)

Interfaces:

  • Consumes: Task 2's Ctrl/Alt restriction.

  • Produces: UDPSCOPE_PROBE_ZOOMKEYS_MS, whose line is asserted in Task 11's final verification.

  • Step 1: Write the probe

Add to src/ui.cpp's anonymous namespace, beside the other probe functions:

/// Drives the plot's keyboard zoom with Ctrl and with Alt and reports which axis moved.
/// Run it inside a pinned state (the pause probe makes a stable one) so the `_held`
/// readings mean "the other axis did not move", not "the frame re-framed anyway":
///   UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200
gboolean zoomkeys_probe(gpointer user) {
  App* a = static_cast<App*>(user);
  if (a->rows.rows.empty() || a->rows.rows[0].plot == nullptr) return G_SOURCE_REMOVE;
  plot::Plot& p = *a->rows.rows[0].plot;
  const plot::AxisId ax = a->rows.rows[0].x_axis;
  const plot::AxisId ay = a->rows.rows[0].y_axis;

  plot::input::Controller* c =
      a->area != nullptr ? a->area->row_controller(static_cast<plot::u32>(0)) : nullptr;
  if (c == nullptr) {
    std::fprintf(stderr, "[zoomkeys] no controller for row 0\n");
    std::fflush(stderr);
    return G_SOURCE_REMOVE;
  }

  const plot::Range x0 = p.x_range(ax);
  const plot::Range y0 = p.y_range(ay);
  c->on_key({plot::input::kKeyZoomIn,
             static_cast<plot::u32>(plot::input::Mod::Ctrl), true});
  const plot::Range x1 = p.x_range(ax);
  const plot::Range y1 = p.y_range(ay);
  const int y_held = std::abs(y1.span() - y0.span()) < 1e-9 ? 1 : 0;

  c->on_key({plot::input::kKeyZoomIn,
             static_cast<plot::u32>(plot::input::Mod::Alt), true});
  const plot::Range x2 = p.x_range(ax);
  const plot::Range y2 = p.y_range(ay);
  const int x_held = std::abs(x2.span() - x1.span()) < 1e-9 ? 1 : 0;

  std::fprintf(stderr,
               "[zoomkeys] ctrl+plus x=[%.4f..%.4f]->[%.4f..%.4f] y_held=%d | "
               "alt+plus y=[%.4f..%.4f]->[%.4f..%.4f] x_held=%d\n",
               x0.min, x0.max, x1.min, x1.max, y_held, y1.min, y1.max, y2.min, y2.max,
               x_held);
  std::fflush(stderr);
  return G_SOURCE_REMOVE;
}

PlotArea::row_controller(plot::u32) is the accessor the app already uses — src/ui.cpp:940 reaches a row's controller through it, and tick() polls it per row at src/ui.cpp:4585-4586 to find the row holding a dragged cursor. Use it; do not add a new accessor to the adapter.

  • Step 2: Register it

Beside the other probe registrations, add:

  if (const char* env = g_getenv("UDPSCOPE_PROBE_ZOOMKEYS_MS")) {
    g_timeout_add(static_cast<guint>(std::atoi(env)), zoomkeys_probe, &app);
  }
  • Step 3: Build

Run: cmake --build build Expected: no warnings.

  • Step 4: Run it inside a pause
python3 tools/mock_udpstreamer.py --duration 20 &
sleep 1
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_PAUSE_MS=1500 \
  UDPSCOPE_PROBE_ZOOMKEYS_MS=2200 UDPSCOPE_EXIT_AFTER_MS=9000 \
  ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep -E 'zoomkeys|pause-probe|gaps'
pkill -f 'mock_udpstream[e]r'; pgrep -f 'mock_udpstream[e]r' || echo "mock stopped"

Expected: one [zoomkeys] line with y_held=1 and x_held=1, with the ctrl range strictly narrower than its -> source and the alt range strictly narrower than its own. A y_held=0 or x_held=0 means Task 2's restriction is not reaching the app; both zooms must still happen (an unchanged range on the restricted axis is the opposite failure — the modifier swallowing the key).

  • Step 5: Commit
git add src/ui.cpp
git commit -m "test: probe the ctrl/alt keyboard zoom restriction through the app"

Task 4: The help window, the page registry, and pages 1-4 (Getting started)

Files:

  • Create: src/help/udpscope-help.gresource.xml
  • Create: src/help/01-overview.txt, 02-source.txt, 03-signals.txt, 04-layout.txt
  • Modify: CMakeLists.txt — a second GResource command and its generated source
  • Modify: src/help_window.hpp, src/help_window.cpp — the registry, the window, the probe report
  • Modify: src/ui.cpp — the Help menu item, and UDPSCOPE_PROBE_HELP_MS

Interfaces:

  • Consumes: Task 1's show_about() and on_window_close() pattern, build_info::*, config_dir().

  • Produces:

    • namespace udpscope { void show_help(GtkWindow* parent); }
    • namespace udpscope { struct HelpReport { int pages; int parsed; std::string shortest_title; std::size_t shortest_bytes; }; HelpReport help_self_check(); }
    • The page registry: adding a page is one .txt file, one <file> line and one registry row. Tasks 5, 6 and 7 add rows to the same table.
  • Step 1: Write the four page bodies

Pango markup, so &, < and > must be escaped as &amp;, &lt;, &gt;. Every page has the same four parts in the same order: an opening paragraph (what it does), How to reach it, Worth knowing, Example. The numbers in the examples are the ones the probes actually report — do not round them.

Create src/help/01-overview.txt:

<b>What udpscope is</b>

udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each
with up to 4 traces; every row shares one time axis, so the whole figure scrolls together.
Acquisition runs on its own thread and writes a ring buffer sized from --buffer-mb; the
drawing thread reads whatever that ring holds when a frame starts.

<b>How to reach it</b>

Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it
with no <tt>--server</tt> and pick one from the chooser. The signals arrive disabled; turn
one on in the side panel (<tt>s</tt>) and drag it onto a row.

<b>Worth knowing</b>

• Nothing is acquired until a signal is enabled. Enabling is what starts acquisition, so a
  freshly opened scope with an idle side panel is working correctly.
• The stream carries its own clock. Every time on screen is a stream timestamp, never the
  local wall clock, which is why a stalled source freezes the view instead of scrolling it.
• The window remembers itself. Rows, traces, the time window, calibration, math channels
  and measurements are written to a session file per host and port, and reloaded next time.

<b>Example</b>

A 3-signal source at 10 kS/s with a 200 MB buffer holds 1677 s of history. Enable all three,
drop them on row 1, and the status bar reads <tt>● live · 10.0 kS/s · 2 s window</tt>.

Create src/help/02-source.txt:

<b>Choosing a source</b>

A source is a host and a port, optionally reached through a multicast group on a named
interface. Pass it on the command line, or let udpscope ask.

<b>How to reach it</b>

Run with <tt>--server</tt> and the app connects straight away. Run <i>without</i>
<tt>--server</tt> and a chooser appears first: the sessions udpscope has stored, newest
first, plus a <b>New host…</b> form for one it has not seen. The chooser runs before any
buffer is allocated and before any socket is opened, so declining it costs nothing.

<b>Worth knowing</b>

• The list is the sessions that <i>named a signal</i>. A session whose streamer was down
  stores empty slot names and is filtered out, which is what keeps the list the sources you
  actually configured rather than every port you have ever typed.
• A stored row reconnects the way it was reached. The multicast group and the interface are
  written inside the session file, because the file's name (<tt>host_port.toml</tt>) cannot
  carry them.
• <tt>--multicast</tt> still needs <tt>--server</tt>: the CONFIG message is fetched from the
  server, not from the group.
• The <tt>×</tt> beside a row deletes that session file. Nothing else is removed.

<b>Example</b>

<tt>udpscope</tt> with two stored sessions lists
<tt>127.0.0.1:8000  plots=1 signals=3</tt> and
<tt>10.0.0.5:8001  plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9</tt>.
Activating the first reports <tt>[sources] picked 127.0.0.1:8000</tt> and continues into
the scope with that session's layout already loaded.

Create src/help/03-signals.txt:

<b>Signals and calibration</b>

The source's CONFIG message describes its signals: a name, a unit and a type. udpscope turns
each enabled signal into a <i>channel</i> — a ring of values beside the shared time ring —
and a channel is what a trace plots. Calibration is a scale and an offset applied at ingest.

<b>How to reach it</b>

The side panel (<tt>s</tt>) lists every signal with a switch. The <b>Signal list</b> item in
the overflow menu opens the editor where the unit and the calibration live.

<b>Worth knowing</b>

• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
  nobody acquires can never fire, so arming a trigger enables its signal for you.
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
  history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
  for a second after one of those is the ring refilling, not a fault.
• The stream's first signal is usually its <i>timestamp</i>, and that one is never acquired.
  It cannot be plotted, measured or used as a math source, and udpscope refuses to put it in
  a row rather than binding a trace that would read nothing for ever.
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
  is one colour and the side panel's chip identifies it unambiguously. A signal on no row
  gets a grey chip rather than none, so the panel's rows keep their height.

<b>Example</b>

A source declaring <tt>time</tt>, <tt>sine</tt>, <tt>ramp</tt> and <tt>noise</tt> yields three
plottable signals. Enable all three and the exit dump reads <tt>3 signals, 3 channels</tt>;
<tt>time</tt> is not among them.

Create src/help/04-layout.txt:

<b>Rows, layout and drag &amp; drop</b>

The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
time axis and one left gutter, so their y labels line up and their windows cannot drift apart.

<b>How to reach it</b>

Drag a signal's chip out of the side panel (<tt>s</tt>) and drop it on a row to add a trace.
Press <tt>l</tt>, or pick <b>Layout editor</b> from the overflow menu, to add and remove rows:
a <tt>×</tt> appears at the top left of each row and a <tt>+</tt> on each boundary between
rows, including above the first and below the last. The editor's own button in the top-right
corner leaves it.

<b>Worth knowing</b>

• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
  traces already on a row the drop is refused.
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
  puts what row 1 plotted onto row 2 and leaves the new row empty — the rows are not
  renumbered under you.
• A trace is remembered by the signal's <i>name</i>, not by its channel number, because a new
  CONFIG or an enable renumbers the channels and an index would silently re-point the trace.
• Adding or removing a row rebuilds the figure, which is cheap, but it also re-deals every
  trace. A frozen trigger event survives the rebuild: the pinned window is restated rather
  than re-framed on the live edge.

<b>Example</b>

With three traces on row 1, pressing <tt>l</tt> shows 3 buttons (one <tt>×</tt>, two
<tt>+</tt>). Clicking the <tt>+</tt> above row 1 gives 2 rows and 5 buttons, with the three
traces now on row 2 — the layout probe reports exactly this as
<tt>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</tt>.
  • Step 2: Write the GResource manifest

Create src/help/udpscope-help.gresource.xml:

<?xml version="1.0" encoding="UTF-8"?>
<!--
  SPDX-License-Identifier: MIT

  The help pages, compiled into the binary for the same reason the icons are: 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 a help window full
  of missing pages.
-->
<gresources>
  <gresource prefix="/org/udpscope/help">
    <file>01-overview.txt</file>
    <file>02-source.txt</file>
    <file>03-signals.txt</file>
    <file>04-layout.txt</file>
  </gresource>
</gresources>

Tasks 5, 6 and 7 add their <file> lines here.

  • Step 3: Compile the bundle in CMake

In CMakeLists.txt, after the icon GResource add_custom_command (line 52) and before the build-info block Task 1 added, insert:

# --- help pages: Pango markup in a GResource ----------------------------------
# A second bundle beside the icons'. --c-name is given explicitly so the two generated
# sources cannot collide on symbol names.
set(UDPSCOPE_HELP_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/help)
file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS ${UDPSCOPE_HELP_DIR}/*.txt)
add_custom_command(
  OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
  COMMAND ${GLIB_COMPILE_RESOURCES}
          --target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
          --sourcedir=${UDPSCOPE_HELP_DIR}
          --c-name=udpscope_help
          --generate-source
          ${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml
  DEPENDS ${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml ${UDPSCOPE_HELP_FILES}
  COMMENT "Compiling udpscope help resources"
  VERBATIM)

and add the generated source to add_executable, beside the icons':

  ${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c

--generate-source emits a constructor that registers the bundle, so no explicit g_resources_register() call is needed — the same mechanism the icons rely on.

  • Step 4: Extend the help_window header

In src/help_window.hpp, add below show_about():

/// The paged help window. Modal on nothing — it is a reference, so it stays usable while
/// the scope runs. Already open: presented.
void show_help(GtkWindow* parent);

/// What UDPSCOPE_PROBE_HELP_MS reports. `parsed` counts pages whose markup
/// pango_parse_markup() accepted and whose body is non-empty; it must equal `pages`.
struct HelpReport {
  int pages = 0;
  int parsed = 0;
  std::string shortest_title;
  std::size_t shortest_bytes = 0;
};

/// Loads and validates every page without building a window. Safe to call headless.
HelpReport help_self_check();

and add #include <cstddef> beside the existing includes.

  • Step 5: Implement the registry, the loader and the window

In src/help_window.cpp, add to the anonymous namespace (above show_about):

GtkWidget* g_help = nullptr;

struct HelpPage {
  const char* section;   ///< the group heading in the sidebar
  const char* title;     ///< the row's label and the page's heading
  const char* resource;  ///< a path under /org/udpscope/help
};

/// Adding a page is one .txt file, one <file> line in the manifest and one row here.
/// The order here is the order the sidebar shows; `section` groups consecutive rows.
constexpr HelpPage kPages[] = {
    {"Getting started", "Overview", "/org/udpscope/help/01-overview.txt"},
    {"Getting started", "Choosing a source", "/org/udpscope/help/02-source.txt"},
    {"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.txt"},
    {"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"},
};
constexpr int kPageCount = static_cast<int>(sizeof(kPages) / sizeof(kPages[0]));

/// The page's markup, or a one-line stand-in. `ok` reports whether the real body loaded
/// *and* parsed: a missing resource and markup Pango rejects both render the stand-in,
/// because a help window that fails to open is worse than one page that says so.
std::string load_page(const HelpPage& page, bool* ok) {
  *ok = false;
  GError* error = nullptr;
  GBytes* bytes = g_resources_lookup_data(page.resource, G_RESOURCE_LOOKUP_FLAGS_NONE,
                                          &error);
  if (bytes == nullptr) {
    if (error != nullptr) g_error_free(error);
    return "<i>This page is missing from the build.</i>";
  }
  gsize size = 0;
  const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &size));
  std::string body(data, size);
  g_bytes_unref(bytes);

  if (!pango_parse_markup(body.c_str(), -1, 0, nullptr, nullptr, nullptr, &error)) {
    if (error != nullptr) g_error_free(error);
    return "<i>This page could not be displayed: its markup is malformed.</i>";
  }
  *ok = true;
  return body;
}

void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label) {
  if (row == nullptr) return;
  const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page"));
  if (idx < 0 || idx >= kPageCount) return;
  bool ok = false;
  const std::string body = load_page(kPages[idx], &ok);
  gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
}

Then, after show_about(), add:

HelpReport help_self_check() {
  HelpReport report;
  report.pages = kPageCount;
  for (const HelpPage& page : kPages) {
    bool ok = false;
    const std::string body = load_page(page, &ok);
    if (ok && !body.empty()) ++report.parsed;
    if (report.shortest_title.empty() || body.size() < report.shortest_bytes) {
      report.shortest_title = page.title;
      report.shortest_bytes = body.size();
    }
  }
  return report;
}

void show_help(GtkWindow* parent) {
  if (g_help != nullptr) {
    gtk_window_present(GTK_WINDOW(g_help));
    return;
  }

  GtkWidget* window = gtk_window_new();
  g_help = window;
  gtk_window_set_title(GTK_WINDOW(window), "udpscope help");
  gtk_window_set_default_size(GTK_WINDOW(window), 860, 640);
  gtk_window_set_transient_for(GTK_WINDOW(window), parent);

  // One label, width-locked by the scrolled window, holding one page's markup. The
  // sidebar swaps the markup rather than rebuilding anything.
  GtkWidget* body = gtk_label_new(nullptr);
  gtk_label_set_use_markup(GTK_LABEL(body), TRUE);
  gtk_label_set_wrap(GTK_LABEL(body), TRUE);
  gtk_label_set_xalign(GTK_LABEL(body), 0.0f);
  gtk_label_set_yalign(GTK_LABEL(body), 0.0f);
  gtk_label_set_selectable(GTK_LABEL(body), TRUE);
  gtk_widget_set_margin_start(body, 18);
  gtk_widget_set_margin_end(body, 18);
  gtk_widget_set_margin_top(body, 14);
  gtk_widget_set_margin_bottom(body, 14);

  GtkWidget* body_scroll = gtk_scrolled_window_new();
  gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
                                 GTK_POLICY_AUTOMATIC);
  gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), body);
  gtk_widget_set_hexpand(body_scroll, TRUE);

  GtkWidget* list = gtk_list_box_new();
  gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_SINGLE);
  const char* current_section = nullptr;
  for (int i = 0; i < kPageCount; ++i) {
    if (current_section == nullptr ||
        std::string(current_section) != kPages[i].section) {
      current_section = kPages[i].section;
      GtkWidget* heading = gtk_label_new(nullptr);
      char markup[128];
      std::snprintf(markup, sizeof(markup), "<b><small>%s</small></b>", current_section);
      gtk_label_set_markup(GTK_LABEL(heading), markup);
      gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
      gtk_widget_set_margin_start(heading, 10);
      gtk_widget_set_margin_top(heading, 10);
      gtk_widget_set_margin_bottom(heading, 2);
      GtkWidget* heading_row = gtk_list_box_row_new();
      gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(heading_row), FALSE);
      gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(heading_row), FALSE);
      gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(heading_row), heading);
      gtk_list_box_append(GTK_LIST_BOX(list), heading_row);
    }
    GtkWidget* label = gtk_label_new(kPages[i].title);
    gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
    gtk_widget_set_margin_start(label, 18);
    gtk_widget_set_margin_end(label, 10);
    gtk_widget_set_margin_top(label, 4);
    gtk_widget_set_margin_bottom(label, 4);
    GtkWidget* row = gtk_list_box_row_new();
    gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
    g_object_set_data(G_OBJECT(row), "help-page", GINT_TO_POINTER(i));
    gtk_list_box_append(GTK_LIST_BOX(list), row);
  }
  g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), body);

  GtkWidget* list_scroll = gtk_scrolled_window_new();
  gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(list_scroll), GTK_POLICY_NEVER,
                                 GTK_POLICY_AUTOMATIC);
  gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(list_scroll), list);
  gtk_widget_set_size_request(list_scroll, 220, -1);

  GtkWidget* split = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
  gtk_box_append(GTK_BOX(split), list_scroll);
  gtk_box_append(GTK_BOX(split), gtk_separator_new(GTK_ORIENTATION_VERTICAL));
  gtk_box_append(GTK_BOX(split), body_scroll);
  gtk_window_set_child(GTK_WINDOW(window), split);

  // Select the first page so the window never opens blank.
  GtkListBoxRow* first = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 1);
  if (first != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(list), first);

  g_signal_connect(window, "close-request", G_CALLBACK(on_window_close), &g_help);
  gtk_window_present(GTK_WINDOW(window));
}

Add #include <string> and #include <cstdio> if they are not already present. The sidebar's first row is a non-selectable section heading, hence get_row_at_index(list, 1) for the first real page.

  • Step 6: Add the Help menu item

In src/ui.cpp's build_toolbar(), before the About item added in Task 1, append:

  GtkWidget* help_item = gtk_button_new_with_label("Help (F1)");
  gtk_widget_set_tooltip_text(help_item, "the paged guide to every feature");
  g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
  gtk_box_append(GTK_BOX(menu_box), help_item);

and the handler beside on_about_clicked, dismissing the menu popover the same way it does — without this the popover stays up behind the new window:

void on_help_clicked(GtkButton*, gpointer user) {
  App* app = static_cast<App*>(user);
  if (app->menu_button != nullptr) {
    GtkWidget* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
    if (popover != nullptr) gtk_popover_popdown(GTK_POPOVER(popover));
  }
  show_help(GTK_WINDOW(app->window));
}
  • Step 7: Add the help probe

In src/ui.cpp's anonymous namespace:

/// Loads and validates every help page, opens all three windows, closes them, and reports.
/// `parsed` must equal `pages` and no page may be empty; `closed ok` with no GTK critical
/// is the crash check for three windows built and destroyed in one run.
gboolean help_probe(gpointer user) {
  App* a = static_cast<App*>(user);
  const HelpReport report = help_self_check();
  const std::string session =
      layout_path(a->model->config().server, a->model->config().port);

  show_help(GTK_WINDOW(a->window));
  show_about(GTK_WINDOW(a->window), session);
  close_help_windows();

  std::fprintf(stderr,
               "[help] pages=%d parsed=%d shortest='%s' %zu B; "
               "about version=%s commit=%s built=%s settings=%s; closed ok\n",
               report.pages, report.parsed, report.shortest_title.c_str(),
               report.shortest_bytes, build_info::kVersion, build_info::kCommit,
               build_info::kTimestamp, config_dir().c_str());
  std::fflush(stderr);
  return G_SOURCE_REMOVE;
}

Task 8 extends this same line with a shortcuts: app=12 plot=13; field and adds show_shortcuts() to the window sequence; this task prints the line without that field rather than printing a stub for it. Add the include "build_info.hpp"; "help_window.hpp" is already included from Task 1.

close_help_windows() is a new export — add to src/help_window.hpp:

/// Closes whichever of the three windows are open, via gtk_window_close() so the
/// close-request handlers run and the pointers are nulled. Used by the probe.
void close_help_windows();

and to src/help_window.cpp:

void close_help_windows() {
  if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
  if (g_about != nullptr) gtk_window_close(GTK_WINDOW(g_about));
}

Task 8 adds its window to this function. Register the probe beside the others:

  if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
    g_timeout_add(static_cast<guint>(std::atoi(env)), help_probe, &app);
  }
  • Step 8: Build

Run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo && cmake --build build Expected: Compiling udpscope help resources runs, and the build is warning-free.

  • Step 9: Run the probe
python3 tools/mock_udpstreamer.py --duration 20 &
sleep 1
xvfb-run -a env UDPSCOPE_PROBE_HELP_MS=2000 UDPSCOPE_EXIT_AFTER_MS=7000 \
  ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep -E '\[help\]|CRITICAL|gaps'
pkill -f 'mock_udpstream[e]r'; pgrep -f 'mock_udpstream[e]r' || echo "mock stopped"

Expected: [help] pages=4 parsed=4 shortest='...' NNN B; about version=0.1.0 commit=... built=... settings=/home/.../.config/udpscope; closed ok, no Gtk-CRITICAL, and the usual gaps 0. A parsed=3 means one page's markup is malformed — the loader's stand-in body is what you will see in the window, so read parsed, not the window.

  • Step 10: Commit
git add src/help src/help_window.hpp src/help_window.cpp src/ui.cpp CMakeLists.txt
git commit -m "feat: paged help window with the four Getting started pages"

Task 5: Pages 5-8 (Viewing)

Files:

  • Create: src/help/05-window.txt, 06-navigate.txt, 07-pause.txt, 08-trigger.txt
  • Modify: src/help/udpscope-help.gresource.xml — four <file> lines
  • Modify: src/help_window.cpp — four rows in kPages

Interfaces:

  • Consumes: Task 4's kPages registry, the /org/udpscope/help prefix, and help_self_check()'s parsed count.

  • Produces: nothing new. The help probe's pages= rises from 4 to 8.

  • Step 1: Write the four page bodies

Create src/help/05-window.txt. This is the spec's approved template page, in the markup style of the pages Task 4 wrote:

<b>Time window</b>

The window is how much time the plot shows. Every row shares one window: the figure has a
single x link group, so the rows cannot drift apart. The trigger's event span follows it, and
the pre-trigger share is a share of <i>this</i> window.

<b>How to reach it</b>

The header bar's window button opens a popover: type a number, pick a unit under it, or step a
decade with the arrows. The button's own label is a reading of the current window in a natural
unit, width-locked so a change of window cannot widen the header bar.

<b>Worth knowing</b>

• A window too long to store at the wire rate is <i>decimated, not refused</i>. The shared clock
  takes a power-of-two stride, and each group of samples is <b>averaged</b> rather than strided,
  so a tone above the stored Nyquist is attenuated instead of aliasing to a false low frequency.
• The stamp stored for a group is the group's <i>mean</i>, which is where the averaging puts the
  value. Stamping at the end of a group would shift every trace half a stored sample late.
• Moving the window across a decimation boundary rebuilds the buffer and drops the history.
  Walking it inside one decade is free.
• The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge
  found on a sample that is not in the buffer could not be cut out of it.

<b>Example</b>

1 MS/s with 200 MB of buffer. A 2 s window stores every sample (<tt>time x1</tt>). Widen it to
600 s and the stride becomes <tt>x16</tt>: 625 S/s stored, anti-aliased at 312.5 Hz. A 2 kHz
sine seen through that window reads <tt>y=[-0.059, 0.079]</tt> — attenuated to about 6%, not
folded down to a full-amplitude 125 Hz one.

Create src/help/06-navigate.txt:

<b>Navigating the plot</b>

The plot pans and zooms with the mouse and with the keyboard. Every gesture acts on the row the
pointer is over, and because the rows share one time axis, an x change moves all of them.

<b>How to reach it</b>

<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
<b>Drag</b> pans · <b>Ctrl+drag</b> is a box zoom on x · <b>Alt+drag</b> a box zoom on y.
<b>+</b> and <b>-</b> zoom both axes; <b>Ctrl</b> with them restricts to x, <b>Alt</b> to y.
<b>Arrow keys</b> pan, <b>f</b> fits, <b>Escape</b> cancels a gesture in progress.
The full table is in the shortcut overview (<tt>Ctrl+/</tt>).

<b>Worth knowing</b>

• The keyboard zoom's modifiers are <i>not</i> the wheel's, and that is deliberate: on common
  layouts <tt>+</tt> <i>is</i> shift+<tt>=</tt>, so a bare zoom-in already arrives with Shift
  set and Shift could not mean anything. Ctrl/Alt mirror the box-zoom drags instead.
• A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is
  held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not
  outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data.
• A live view is clamped to the window you asked for, not to what the ring happens to hold.
  That is why a buffer that has only just started filling leaves its left-hand side empty
  instead of squeezing the window down to the first few samples.
• Zooming hard into a held capture is exact. The renderer finds the visible samples by binary
  search over the real timestamps, never over a nominal sample grid, so a capture whose clock
  wandered does not drift away from its axis as you zoom.

<b>Example</b>

Pause a live view, then press <tt>Ctrl++</tt>: x narrows and y does not move. Press
<tt>Alt++</tt>: y narrows and x does not. Eight doublings into a held 2 s capture, the trace
still reaches both margins — the zoom probe reports <tt>gap_px</tt> and <tt>end_px</tt> within
a pixel or two at every step.

Create src/help/07-pause.txt:

<b>Pause and play</b>

Pause freezes the <i>display</i>, not the acquisition. The producer keeps filling the ring at
full rate; the view binds an immutable snapshot of the window that was on screen, and the axis
is pinned to it.

<b>How to reach it</b>

The pause button in the toolbar, or <tt>Space</tt>. Press it again to resume, which rebinds the
live ring and puts the window back on the newest data.

<b>Worth knowing</b>

• Nothing is lost while paused. Because the producer never stops, resuming shows current data
  with no hole in the history behind it.
• The axis is pinned to the <i>snapshot's own stamps</i>, not to the window that was requested.
  A window reaching back past the oldest retained sample yields a shorter snapshot, and the
  pinned axis is that shorter span — so you see a narrower view rather than an empty margin.
• A paused view is pinned exactly as a held trigger event is: zoom in freely, but a pan or a
  zoom out is clamped to the snapshot's data.
• Pausing is the easiest way to measure a layout or read a cursor precisely, because nothing
  under the pointer moves between frames.

<b>Example</b>

Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads
<tt>[-1.40 .. 0.00]</tt> rather than <tt>[-2.00 .. 0.00]</tt> with 0.6 s of blank. The pause
probe reports <tt>ring_produced</tt> still growing across the pause and
<tt>event_size</tt> non-zero exactly while paused.

Create src/help/08-trigger.txt:

<b>Trigger</b>

The trigger watches one signal for an edge through a level and captures a window around it.
The captured window is frozen on screen until you re-arm.

<b>How to reach it</b>

The trigger button in the toolbar (<tt>t</tt>) reveals the trigger panel: the signal, the level,
the edge (rising, falling, either), the mode (Normal or Single) and the pre-trigger share.
<b>Arm</b> starts watching; <tt>a</tt> arms and disarms from the keyboard.

<b>Worth knowing</b>

• Arming <i>enables</i> the signal if it was off, because a trigger on a signal nobody acquires
  can never fire.
• The pre-trigger share is a share of the <i>display window</i>, not of the ring. A 2 s window
  at 50% captures 1 s before the edge and 1 s after.
• There are four display states, and the status bar names each: <tt>armed</tt> (watching,
  nothing drawn yet), <tt>capturing</tt> (the edge was accepted and the post window is filling
  <i>in view</i>, with the marker at x = 0), <tt>event held</tt> (the frozen capture) and
  <tt>disarmed</tt>. A held capture is never quietly replaced by live data.
• The first edge after arming is only accepted once the history reaches back a full pre-trigger
  window, so a capture may legitimately be shorter than you asked for. The axis is pinned to
  what the capture actually got.
• Disarming <i>during</i> a capture publishes what the capture got to, so the picture on screen
  at that moment stays on screen. No event fired, so the event count does not move.
• The trigger reads the <b>stored</b> signal — decimated and averaged — not the wire signal.
  A spike narrower than one decimation group is filtered away rather than triggering on data
  the buffer does not contain.

<b>Example</b>

A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans
<tt>[-1.000 .. 1.000]</tt> with <tt>n=20001</tt> samples at 10 kS/s — the span times the real
rate, plus one — and the marker sits at x = 0 throughout.
  • Step 2: Add them to the manifest

In src/help/udpscope-help.gresource.xml, after <file>04-layout.txt</file>:

    <file>05-window.txt</file>
    <file>06-navigate.txt</file>
    <file>07-pause.txt</file>
    <file>08-trigger.txt</file>
  • Step 3: Add them to the registry

In src/help_window.cpp's kPages, after the 04-layout row:

    {"Viewing", "Time window", "/org/udpscope/help/05-window.txt"},
    {"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"},
    {"Viewing", "Pause and play", "/org/udpscope/help/07-pause.txt"},
    {"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt"},
  • Step 4: Build

Run: cmake --build build Expected: Compiling udpscope help resources re-runs (the file(GLOB ... CONFIGURE_DEPENDS) notices the new files; if it does not, re-run cmake -S . -B build), warning-free.

  • Step 5: Run the help probe
python3 tools/mock_udpstreamer.py --duration 20 &
sleep 1
xvfb-run -a env UDPSCOPE_PROBE_HELP_MS=2000 UDPSCOPE_EXIT_AFTER_MS=7000 \
  ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep -E '\[help\]|CRITICAL'
pkill -f 'mock_udpstream[e]r'; pgrep -f 'mock_udpstream[e]r' || echo "mock stopped"

Expected: [help] pages=8 parsed=8 ...; closed ok and no Gtk-CRITICAL. parsed below pages names a markup error — most likely an unescaped & or < in one of the four new files.

  • Step 6: Commit
git add src/help src/help_window.cpp
git commit -m "docs: help pages for the time window, navigation, pause and trigger"

Task 6: Pages 9-11 (Analysis)

Files:

  • Create: src/help/09-cursors.txt, 10-measurements.txt, 11-math.txt
  • Modify: src/help/udpscope-help.gresource.xml — three <file> lines
  • Modify: src/help_window.cpp — three rows in kPages

Interfaces:

  • Consumes: Task 4's kPages registry.

  • Produces: nothing new. pages= rises from 8 to 11.

  • Step 1: Write the three page bodies

Create src/help/09-cursors.txt:

<b>Cursors</b>

Cursors read values off the traces. There are two kinds: one <i>shared vertical pair</i> that
every row carries at the same time, and <i>horizontal rulers</i>, one pair per row, that belong
to that row alone.

<b>How to reach it</b>

The cursors button in the toolbar, or <tt>c</tt>. The pane below the plot shows the readings and
owns a <b>Reset</b> button. Drag a marker to move it. The mode dropdown offers four modes:
<tt>vertical</tt> (the shared time pair), <tt>horizontal</tt> (the per-row rulers),
<tt>both</tt>, and <tt>mix</tt>.

<b>Worth knowing</b>

• The vertical pair is <i>one</i> time for the whole figure, so moving it on any row moves it on
  all of them. That is what makes <tt>dt</tt> meaningful across rows.
• A ruler belongs to its row and nothing shares it, so two rows' rulers are independent.
• <tt>mix</tt> is the reading mode: one line per <i>bound</i> trace, with the trace's own values
  at each marker and their difference. A slot with a name but no channel behind it is not a
  trace and gets no line.
• A marker past the newest sample reads <tt>--</tt>. With only one marker over data the row
  reads <tt>partial</tt>: a difference between a number and nothing is not a number.
• In <tt>mix</tt> each marker also places a crossing annotation per trace, which is what lets
  you see on the plot which sample a number came from.
• The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
  time row, by design.

<b>Example</b>

Three traces in <tt>mix</tt> mode with both markers over data: 3 lines and 6 crossing marks,
each line reading like <tt>sine  0.31 V  -0.84 V  -1.15 V</tt>. Move the second marker past the
newest sample and the third column reads <tt>--</tt>.

Create src/help/10-measurements.txt:

<b>Measurements</b>

A measurement is a statistic of the <i>visible window</i> of one signal, recomputed as that
window moves. The pane lists them with their values; each can also draw its result on the plot.

<b>How to reach it</b>

The measurements button in the toolbar, or <tt>m</tt>. The pane's <tt>+</tt> adds one; clicking
a row opens the dialog where the signal, the type, the threshold share and the on-plot marks are
set, with <b>Save</b> and <b>Delete</b>.

<b>Worth knowing</b>

• The types are average, top, bottom, min, max, peak to peak, amplitude, pulse duration, period
  and frequency. <i>top</i> and <i>bottom</i> are the means of the upper and lower halves, which
  is why a unit sine reads 0.637 (2/π) rather than 1.
• The on-plot marks come in two kinds and the mix is the reading: a <i>level</i> mark is a ruler
  at a value, a <i>time</i> mark is a marker at a position. An average draws one level mark; a
  max draws a level mark <i>and</i> a time mark at the first sample that reached it.
• Marks cannot be dragged. A measurement mark says what the measurement measured, so it is
  created unselectable — as is the trigger marker.
• A pass is throttled to a cadence rather than run every frame, so a ruler over a live view is
  <i>stepped</i>. The number and the ruler come from the same pass, so they always agree.
• An invalid pass keeps its <i>level</i> marks and drops its <i>time</i> marks. A level is a
  property of the signal and outlives the window; a position inside the window does not. That is
  what stops the rulers blinking off at a mode change.
• A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible
  invented number. Frequency over a window holding one edge is the usual case.
• <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds;
  every other type carries the channel's own unit.
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
  window. The values are still exact: a bucket's sum is a sum.

<b>Example</b>

On a 1 Hz ±1 sine: <tt>average=0</tt>, <tt>top=0.636620</tt>, <tt>bottom=-0.636620</tt>,
<tt>max=1</tt>, <tt>min=-1</tt>, <tt>peak to peak=2</tt>, <tt>amplitude=1</tt>,
<tt>pulse duration=0.5 s</tt> (duty 0.5), <tt>period=1 s</tt>, <tt>frequency=1 Hz</tt>. Over a
2 M-sample window those two passes read about 0.1% of the samples.

Create src/help/11-math.txt:

<b>Math channels</b>

A math channel is a signal computed from other signals. You build it as a small graph — source
nodes, operations, one output — and udpscope then treats the result as an ordinary channel.

<b>How to reach it</b>

The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel; opening one
shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
the canvas, click an output port then an input port to wire them, drag a box to move it. The
orange dot marks the output node.

<b>Worth knowing</b>

• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
  cycle is impossible by construction rather than rejected by a check.
• A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted,
  measured, exported, and used as a source for another math channel.
• The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
  Hz means the rate the filter will actually run at. Change the window across a decimation
  boundary and the coefficients are rebuilt for the new rate.
• A source may only name an <i>acquired</i> channel. The stream's timestamp signal is not one:
  a graph sourced from it validates and then reads NaN for ever, so the dropdown does not
  offer it.
• The output defaults to the <i>last</i> node, and a node added to a linear graph becomes the
  output. Choosing an earlier node explicitly is respected.
• Every numeric field starts at its own identity value — gain 1, offset 0 — not at the bottom
  of its range.

<b>Example</b>

<tt>[sine] → [gain x2]</tt> gives a channel reading <tt>y=[-2, 2]</tt>. A 100 Hz low pass on the
same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at
0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel must hold exactly as many samples as
the shared clock has time slots.
  • Step 2: Add them to the manifest

In src/help/udpscope-help.gresource.xml, after <file>08-trigger.txt</file>:

    <file>09-cursors.txt</file>
    <file>10-measurements.txt</file>
    <file>11-math.txt</file>
  • Step 3: Add them to the registry

In src/help_window.cpp's kPages, after the 08-trigger row:

    {"Analysis", "Cursors", "/org/udpscope/help/09-cursors.txt"},
    {"Analysis", "Measurements", "/org/udpscope/help/10-measurements.txt"},
    {"Analysis", "Math channels", "/org/udpscope/help/11-math.txt"},
  • Step 4: Build

Run: cmake --build build Expected: warning-free, with the help resources recompiled.

  • Step 5: Run the help probe
python3 tools/mock_udpstreamer.py --duration 20 &
sleep 1
xvfb-run -a env UDPSCOPE_PROBE_HELP_MS=2000 UDPSCOPE_EXIT_AFTER_MS=7000 \
  ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep -E '\[help\]|CRITICAL'
pkill -f 'mock_udpstream[e]r'; pgrep -f 'mock_udpstream[e]r' || echo "mock stopped"

Expected: [help] pages=11 parsed=11 ...; closed ok, no Gtk-CRITICAL.

  • Step 6: Commit
git add src/help src/help_window.cpp
git commit -m "docs: help pages for cursors, measurements and math channels"

Task 7: Pages 12-14 (Output and state)

Files:

  • Create: src/help/12-export.txt, 13-sessions.txt, 14-status.txt
  • Modify: src/help/udpscope-help.gresource.xml — three <file> lines
  • Modify: src/help_window.cpp — three rows in kPages

Interfaces:

  • Consumes: Task 4's kPages registry.

  • Produces: nothing new. pages= reaches its final 14, which Task 11's verification asserts.

  • Step 1: Write the three page bodies

Page 14 describes the status bar this plan is about to change, so it is written to the shape Task 10 builds, not to the current bar.

Create src/help/12-export.txt:

<b>Saving and export</b>

Export writes the <i>visible range</i> of every plotted trace to a file: NumPy <tt>.npy</tt> or
<tt>.csv</tt>, chosen by the extension you type.

<b>How to reach it</b>

<b>Export visible range…</b> in the overflow menu, or <tt>Ctrl+E</tt>. A file chooser asks where;
the format follows the path's extension.

<b>Worth knowing</b>

• The table is collected at the moment you click, not when the chooser returns. A live view keeps
  scrolling while a dialog is up, so a late collection would write a window you never saw.
• The rows are the <b>stored samples</b>, never the LOD buckets the plot draws from. A row count
  far below the span times the stored rate means something cut the table from the pyramid.
• The stored rate is not always the wire rate. A window long enough to need decimation is stored
  averaged at a lower rate, and the export carries <i>that</i> — read the status bar's ⓘ popover
  for the figure before trusting a sample count.
• Two time columns. <tt>time</tt> is the display timeline (relative to the trigger edge while an
  event is held) and <tt>stream_time</tt> is the source's own clock. Both are strictly increasing
  and evenly spaced; duplicate or backwards stamps would mean the file held two clocks.
• An export taken <i>mid-capture</i> writes the partly filled window that is on screen, which is
  shorter than the event span. Export outside a capture if you want the whole event.

<b>Example</b>

A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
(<tt>time</tt>, <tt>stream_time</tt>, three signals). The <tt>.npy</tt> is
<tt>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</tt> bytes. Read it back with
<tt>pd.DataFrame(np.load(path))</tt>.

Create src/help/13-sessions.txt:

<b>Sessions and settings</b>

udpscope stores one session file per source, so reopening a source gives you the scope you
closed. There is no global preferences file: the session <i>is</i> the settings.

<b>How to reach it</b>

Nothing to open — the file is written as you work. Its path is in the About dialog, and the
directory is <tt>$XDG_CONFIG_HOME/udpscope</tt>, or <tt>~/.config/udpscope</tt> when that is
unset. Each file is named <tt>&lt;host&gt;_&lt;port&gt;.toml</tt>.

<b>Worth knowing</b>

• What a session persists: the time window, the rows and each row's slot signals, the
  calibration, which signals are enabled, the math channels with their graphs, and the
  measurements pane.
• Signal identity is the <i>name</i> throughout, because a new CONFIG renumbers the channels.
• A file written by a run against a source whose streamer was down would name no signals at all,
  so such a run does <b>not</b> write: a configured session is never overwritten by a run that
  configured nothing.
• The multicast group and the interface live <i>inside</i> the file, because its name cannot
  carry them. A file from an older build has neither key and loads as unicast on the default
  route.
• The startup chooser lists only files that name at least one signal, which is what keeps it the
  sources you configured rather than every port you have ever tested against.
• Deleting a file is a supported way to start a source over; the chooser's <tt>×</tt> does
  exactly that.

<b>Example</b>

Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1 and set a 2 s window.
<tt>~/.config/udpscope/127.0.0.1_8000.toml</tt> now holds <tt>rows=1 window_s=2</tt> with
<tt>signals = ["sine", "ramp", "noise", ""]</tt>, and the next run reports
<tt>layout loaded from …/127.0.0.1_8000.toml</tt> with those signals already acquired.

Create src/help/14-status.txt:

<b>Status bar and diagnostics</b>

The status bar is two tiers. The bar itself is one glance line — the state, the rate and the
window — and everything else is behind the ⓘ button at its right-hand end.

<b>How to reach it</b>

The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popover.

<b>Worth knowing</b>

• The badge is one glyph in three colours: lit and dim alternating means data is arriving, and a
  steady idle colour means it is not. A badge that never blinks is not a working badge.
• The state field is one word from a fixed set: <tt>live</tt>, <tt>paused</tt>, <tt>armed</tt>,
  <tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. In trigger mode the bar adds
  exactly one more field, how long ago the last event was.
• Nothing on the bar may change width with the stream, because the bar sits directly above the
  plot and a wider label would relayout it. That is why the rate collapses to one figure or a
  range rather than listing per channel, and why an elapsed time steps to a coarser unit instead
  of growing digits.
• <tt>gaps</tt> counts packets the receiver knows it missed. It is the number that matters: a
  non-zero count means samples are absent from the buffer, not merely late.
• <tt>reconnects</tt> counts sessions re-established with the source. Over unicast a climbing
  count usually means a second client is taking the source's single session.
• The starvation notice — <i>connected, but no new frames in the last second</i> — is that same
  situation seen from the other side. Pointing the app at a port where nothing answers produces
  it too.
• <tt>stored</tt> versus <tt>wire</tt> in the popover is the decimation. They differ exactly
  when the window is too long to store at full rate.

<b>Example</b>

The bar reads <tt>● live · 10.0 kS/s · 2 s window</tt>. Behind ⓘ:
<tt>Stream  connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3 channels</tt>,
<tt>Buffer  200.0 MB · 1677 s span · time x1 / stored 10.0 kS/s (wire 10.0 kS/s)</tt>,
<tt>Plot 1  3 traces · 195000 samples</tt>, <tt>Reconnects  0</tt>.
  • Step 2: Add them to the manifest

In src/help/udpscope-help.gresource.xml, after <file>11-math.txt</file>:

    <file>12-export.txt</file>
    <file>13-sessions.txt</file>
    <file>14-status.txt</file>
  • Step 3: Add them to the registry

In src/help_window.cpp's kPages, after the 11-math row:

    {"Output and state", "Saving and export", "/org/udpscope/help/12-export.txt"},
    {"Output and state", "Sessions and settings", "/org/udpscope/help/13-sessions.txt"},
    {"Output and state", "Status bar and diagnostics", "/org/udpscope/help/14-status.txt"},
  • Step 4: Build

Run: cmake --build build Expected: warning-free.

  • Step 5: Run the help probe
python3 tools/mock_udpstreamer.py --duration 20 &
sleep 1
xvfb-run -a env UDPSCOPE_PROBE_HELP_MS=2000 UDPSCOPE_EXIT_AFTER_MS=7000 \
  ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep -E '\[help\]|CRITICAL'
pkill -f 'mock_udpstream[e]r'; pgrep -f 'mock_udpstream[e]r' || echo "mock stopped"

Expected: [help] pages=14 parsed=14 ...; closed ok, no Gtk-CRITICAL.

  • Step 6: Commit
git add src/help src/help_window.cpp
git commit -m "docs: help pages for export, sessions and the status bar"

Task 8: The shortcut tables and the overview window

Files:

  • Create: src/shortcuts.hpp, src/shortcuts.cpp
  • Modify: CMakeLists.txt — add src/shortcuts.cpp
  • Modify: src/help_window.hpp, src/help_window.cpp — show_shortcuts(), and the window in close_help_windows()
  • Modify: src/ui.cpp — the Shortcuts menu item, and the help probe's new field

Interfaces:

  • Consumes: Task 4's on_window_close() pattern and close_help_windows().
  • Produces:
    • namespace udpscope { enum class AppAction {...}; struct Accel {...}; struct Gesture {...}; const Accel* app_accels(int* count); const Gesture* plot_gestures(int* count); const Accel* find_accel(unsigned keyval, unsigned mods); }
    • namespace udpscope { void show_shortcuts(GtkWindow* parent); }
    • Task 9 consumes find_accel() and AppAction for the key controller.

Deviation from the spec, deliberate: the spec's sample probe line reads shortcuts: app=12 mouse=9. The app count is 12 as specified, but the second table covers the plot's keyboard rows as well as its mouse rows (the overview has both sections), so the field is named plot= and reads 13, the row count of kGestures in Step 2. Reporting a mouse= that silently excluded the plot-keyboard rows would make the count unassertable.

  • Step 1: Write the shortcuts header

Create src/shortcuts.hpp:

// SPDX-License-Identifier: MIT
#pragma once

namespace udpscope {

/// Everything a key can ask the application to do. The plot's own actions (zoom, pan, fit)
/// are not here: they belong to the plotter's input controller, which handles its own keys.
enum class AppAction {
  PauseToggle,
  TriggerArm,
  TriggerPanel,
  CursorsPane,
  MeasuresPane,
  MathPane,
  LayoutEditor,
  SidePanel,
  Export,
  Help,
  Shortcuts,
  Quit,
};

/// One application accelerator: what to press, what it does, and where the overview lists it.
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;
};

/// One documented plot interaction. These have no AppAction: the plotter's own controller
/// implements them, and this table exists so the overview can list them.
struct Gesture {
  const char* accel_text;
  const char* description;
  const char* section;
};

/// The application accelerator table. Writes the row count to `count`.
const Accel* app_accels(int* count);

/// The plot keyboard and mouse table. Writes the row count to `count`.
const Gesture* plot_gestures(int* count);

/// The accelerator for `keyval` with `mods`, or nullptr. `mods` is masked to
/// Ctrl/Shift/Alt before comparing, because Lock, Num-Lock and the pointer buttons
/// arrive in the same mask and would otherwise never match a table row.
const Accel* find_accel(unsigned keyval, unsigned mods);

}  // namespace udpscope
  • Step 2: Write the tables

Create src/shortcuts.cpp:

// SPDX-License-Identifier: MIT
#include "shortcuts.hpp"

#include <gdk/gdk.h>

namespace udpscope {
namespace {

/// Bare letters are toggles, Ctrl is for files and windows. Nothing here shadows a key the
/// plot's own controller handles ('+', '-', 'f', the arrows, Escape).
constexpr Accel kAccels[] = {
    {GDK_KEY_space, 0, AppAction::PauseToggle, "Space", "Pause or resume the display",
     "Acquisition"},
    {GDK_KEY_a, 0, AppAction::TriggerArm, "A", "Arm or disarm the trigger", "Acquisition"},
    {GDK_KEY_t, 0, AppAction::TriggerPanel, "T", "Show or hide the trigger panel",
     "Acquisition"},
    {GDK_KEY_c, 0, AppAction::CursorsPane, "C", "Show or hide the cursors pane",
     "Panes and editors"},
    {GDK_KEY_m, 0, AppAction::MeasuresPane, "M", "Show or hide the measurements pane",
     "Panes and editors"},
    {GDK_KEY_g, 0, AppAction::MathPane, "G", "Show or hide the math pane",
     "Panes and editors"},
    {GDK_KEY_l, 0, AppAction::LayoutEditor, "L", "Show or hide the layout editor",
     "Panes and editors"},
    {GDK_KEY_s, 0, AppAction::SidePanel, "S", "Show or hide the side panel",
     "Panes and editors"},
    {GDK_KEY_e, GDK_CONTROL_MASK, AppAction::Export, "Ctrl+E",
     "Export the visible range", "Files and windows"},
    {GDK_KEY_F1, 0, AppAction::Help, "F1", "Open the help window", "Files and windows"},
    {GDK_KEY_slash, GDK_CONTROL_MASK, AppAction::Shortcuts, "Ctrl+/",
     "Open this shortcut overview", "Files and windows"},
    {GDK_KEY_q, GDK_CONTROL_MASK, AppAction::Quit, "Ctrl+Q", "Quit udpscope",
     "Files and windows"},
};
constexpr int kAccelCount = static_cast<int>(sizeof(kAccels) / sizeof(kAccels[0]));

/// The plot's own interactions, documentation only. The keyboard's modifiers are not the
/// wheel's: '+' is shift+'=' on common layouts, so Shift could not mean anything there.
constexpr Gesture kGestures[] = {
    {"+ / -", "Zoom in or out, both axes", "Plot keyboard"},
    {"Ctrl + / Ctrl -", "Zoom the time axis only", "Plot keyboard"},
    {"Alt + / Alt -", "Zoom the value axis only", "Plot keyboard"},
    {"Left / Right", "Pan along time", "Plot keyboard"},
    {"Up / Down", "Pan along values", "Plot keyboard"},
    {"F", "Fit the view to the data", "Plot keyboard"},
    {"Escape", "Cancel the gesture in progress", "Plot keyboard"},
    {"Wheel", "Zoom about the pointer, both axes", "Plot mouse"},
    {"Shift+wheel", "Zoom the time axis only", "Plot mouse"},
    {"Ctrl+wheel", "Zoom the value axis only", "Plot mouse"},
    {"Drag", "Pan", "Plot mouse"},
    {"Ctrl+drag", "Box zoom on time", "Plot mouse"},
    {"Alt+drag", "Box zoom on values", "Plot mouse"},
};
constexpr int kGestureCount = static_cast<int>(sizeof(kGestures) / sizeof(kGestures[0]));

/// The only modifiers a table row may require. Lock, Num-Lock and the pointer buttons all
/// arrive in the same mask and must be masked out or no row ever matches.
constexpr unsigned kModMask = GDK_CONTROL_MASK | GDK_SHIFT_MASK | GDK_ALT_MASK;

}  // namespace

const Accel* app_accels(int* count) {
  if (count != nullptr) *count = kAccelCount;
  return kAccels;
}

const Gesture* plot_gestures(int* count) {
  if (count != nullptr) *count = kGestureCount;
  return kGestures;
}

const Accel* find_accel(unsigned keyval, unsigned mods) {
  const unsigned wanted = mods & kModMask;
  for (const Accel& accel : kAccels) {
    if (accel.keyval == keyval && accel.mods == wanted) return &accel;
  }
  return nullptr;
}

}  // namespace udpscope

kGestures holds 13 rows: the left/right arrows and the up/down arrows are separate rows because they pan different axes. The probe's plot= field therefore reads 13. If a later edit adds or removes a row, the probe's expected output changes with it — the assertion is that plot= equals std::size(kGestures), which is what plot_gestures() reports.

  • Step 3: Add the source to CMake

In CMakeLists.txt's add_executable, add src/shortcuts.cpp before src/help_window.cpp.

  • Step 4: Write the overview window

In src/help_window.hpp, add:

/// The shortcut overview: the application accelerators and the plot's own interactions,
/// in the sections the tables name. Hand-rolled because GtkShortcutsWindow is deprecated
/// as of GTK 4.18 and this project builds warning-free.
void show_shortcuts(GtkWindow* parent);

In src/help_window.cpp, add #include "shortcuts.hpp", add GtkWidget* g_shortcuts = nullptr; beside the other two pointers, and add:

namespace {

/// One `accel  description` pair appended to `grid` at `row`. The accel column is
/// monospaced and right-aligned so the descriptions line up down the window.
void append_shortcut_row(GtkWidget* grid, int row, const char* accel_text,
                         const char* description) {
  GtkWidget* keys = gtk_label_new(nullptr);
  char markup[256];
  std::snprintf(markup, sizeof(markup), "<tt>%s</tt>", accel_text);
  gtk_label_set_markup(GTK_LABEL(keys), markup);
  gtk_label_set_xalign(GTK_LABEL(keys), 1.0f);
  gtk_widget_set_margin_end(keys, 14);
  gtk_grid_attach(GTK_GRID(grid), keys, 0, row, 1, 1);

  GtkWidget* what = gtk_label_new(description);
  gtk_label_set_xalign(GTK_LABEL(what), 0.0f);
  gtk_grid_attach(GTK_GRID(grid), what, 1, row, 1, 1);
}

/// A section heading spanning both columns.
void append_shortcut_heading(GtkWidget* grid, int row, const char* section) {
  GtkWidget* heading = gtk_label_new(nullptr);
  char markup[128];
  std::snprintf(markup, sizeof(markup), "<b>%s</b>", section);
  gtk_label_set_markup(GTK_LABEL(heading), markup);
  gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
  gtk_widget_set_margin_top(heading, row == 0 ? 0 : 14);
  gtk_widget_set_margin_bottom(heading, 4);
  gtk_grid_attach(GTK_GRID(grid), heading, 0, row, 2, 1);
}

}  // namespace

void show_shortcuts(GtkWindow* parent) {
  if (g_shortcuts != nullptr) {
    gtk_window_present(GTK_WINDOW(g_shortcuts));
    return;
  }

  GtkWidget* window = gtk_window_new();
  g_shortcuts = window;
  gtk_window_set_title(GTK_WINDOW(window), "Keyboard and mouse shortcuts");
  gtk_window_set_default_size(GTK_WINDOW(window), 560, 680);
  gtk_window_set_transient_for(GTK_WINDOW(window), parent);

  GtkWidget* grid = gtk_grid_new();
  gtk_grid_set_row_spacing(GTK_GRID(grid), 2);
  gtk_widget_set_margin_start(grid, 20);
  gtk_widget_set_margin_end(grid, 20);
  gtk_widget_set_margin_top(grid, 16);
  gtk_widget_set_margin_bottom(grid, 16);

  int row = 0;
  const char* section = nullptr;
  int accel_count = 0;
  const Accel* accels = app_accels(&accel_count);
  for (int i = 0; i < accel_count; ++i) {
    if (section == nullptr || std::string(section) != accels[i].section) {
      section = accels[i].section;
      append_shortcut_heading(grid, row++, section);
    }
    append_shortcut_row(grid, row++, accels[i].accel_text, accels[i].description);
  }
  int gesture_count = 0;
  const Gesture* gestures = plot_gestures(&gesture_count);
  for (int i = 0; i < gesture_count; ++i) {
    if (section == nullptr || std::string(section) != gestures[i].section) {
      section = gestures[i].section;
      append_shortcut_heading(grid, row++, section);
    }
    append_shortcut_row(grid, row++, gestures[i].accel_text, gestures[i].description);
  }

  GtkWidget* scroll = gtk_scrolled_window_new();
  gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER,
                                 GTK_POLICY_AUTOMATIC);
  gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), grid);
  gtk_window_set_child(GTK_WINDOW(window), scroll);

  g_signal_connect(window, "close-request", G_CALLBACK(on_window_close), &g_shortcuts);
  gtk_window_present(GTK_WINDOW(window));
}

The tables' rows are grouped by consecutive equal section, which is why both tables keep their sections contiguous. Add this window to close_help_windows():

  if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts));
  • Step 5: Add the Shortcuts menu item

In src/ui.cpp's build_toolbar(), between the Help item and the About item:

  GtkWidget* shortcuts_item = gtk_button_new_with_label("Keyboard shortcuts (Ctrl+/)");
  gtk_widget_set_tooltip_text(shortcuts_item,
                              "every key and mouse gesture, including the plot's own");
  g_signal_connect(shortcuts_item, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
  gtk_box_append(GTK_BOX(menu_box), shortcuts_item);

and the handler beside the other two, dismissing the menu popover the same way they do:

void on_shortcuts_clicked(GtkButton*, gpointer user) {
  App* app = static_cast<App*>(user);
  if (app->menu_button != nullptr) {
    GtkWidget* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
    if (popover != nullptr) gtk_popover_popdown(GTK_POPOVER(popover));
  }
  show_shortcuts(GTK_WINDOW(app->window));
}
  • Step 6: Extend the help probe

In help_probe(), add #include "shortcuts.hpp" at the top of ui.cpp, open the third window in the sequence and report the two counts:

  show_help(GTK_WINDOW(a->window));
  show_about(GTK_WINDOW(a->window), session);
  show_shortcuts(GTK_WINDOW(a->window));
  close_help_windows();

  int accel_count = 0;
  int gesture_count = 0;
  (void)app_accels(&accel_count);
  (void)plot_gestures(&gesture_count);
  std::fprintf(stderr,
               "[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d; "
               "about version=%s commit=%s built=%s settings=%s; closed ok\n",
               report.pages, report.parsed, report.shortest_title.c_str(),
               report.shortest_bytes, accel_count, gesture_count, build_info::kVersion,
               build_info::kCommit, build_info::kTimestamp, config_dir().c_str());
  • Step 7: Build

Run: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo && cmake --build build Expected: warning-free. A -Wdeprecated-declarations here would mean GtkShortcutsWindow crept in; nothing in this task may use it.

  • Step 8: Run the help probe
python3 tools/mock_udpstreamer.py --duration 20 &
sleep 1
xvfb-run -a env UDPSCOPE_PROBE_HELP_MS=2000 UDPSCOPE_EXIT_AFTER_MS=7000 \
  ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep -E '\[help\]|CRITICAL'
pkill -f 'mock_udpstream[e]r'; pgrep -f 'mock_udpstream[e]r' || echo "mock stopped"

Expected: [help] pages=14 parsed=14 shortest='...' NNN B; shortcuts: app=12 plot=13; about version=0.1.0 commit=... built=... settings=...; closed ok, no Gtk-CRITICAL — three windows built, presented and closed in one run is the crash check.

  • Step 9: Commit
git add src/shortcuts.hpp src/shortcuts.cpp src/help_window.hpp src/help_window.cpp \
        src/ui.cpp CMakeLists.txt
git commit -m "feat: keyboard and mouse shortcut overview window"

Task 9: The key controller and the keys probe

Files:

  • Modify: src/ui.cpp — run_app_action(), the key controller, UDPSCOPE_PROBE_KEYS_MS

Interfaces:

  • Consumes: Task 8's find_accel(), AppAction, app_accels(); Task 4's show_help(); Task 8's show_shortcuts(); Task 1's show_about().

  • Produces: run_app_action(App&, AppAction) — the one dispatch point, called by the key handler and by the probe. Nothing later consumes it.

  • Step 1: Write the dispatcher

In src/ui.cpp's anonymous namespace, above build_toolbar() (it needs the App members the toolbar creates, so it must be declared after App and may be defined anywhere in the namespace), add:

/// The one place a key turns into an action. Toggles go through
/// gtk_toggle_button_set_active(), which emits ::toggled and so runs exactly the handler a
/// click would; the two non-toggles call their click handlers with a null button, which they
/// already ignore. Split out of the key handler so the probe can fire a row directly.
void run_app_action(App& app, AppAction action) {
  const auto toggle = [](GtkWidget* button) {
    if (button == nullptr) return;
    const gboolean now = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button));
    gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(button), now == TRUE ? FALSE : TRUE);
  };
  switch (action) {
    case AppAction::PauseToggle:
      if (app.pause_button != nullptr) g_signal_emit_by_name(app.pause_button, "clicked");
      break;
    case AppAction::TriggerArm:
      on_arm_clicked(nullptr, &app);
      break;
    case AppAction::TriggerPanel:
      toggle(app.trigger_toggle);
      break;
    case AppAction::CursorsPane:
      toggle(app.cursors_toggle);
      break;
    case AppAction::MeasuresPane:
      toggle(app.measures_toggle);
      break;
    case AppAction::MathPane:
      toggle(app.math_toggle);
      break;
    case AppAction::LayoutEditor:
      toggle(app.layout_toggle);
      break;
    case AppAction::SidePanel:
      toggle(app.sidebar_toggle);
      break;
    case AppAction::Export:
      on_export_clicked(nullptr, &app);
      break;
    case AppAction::Help:
      show_help(GTK_WINDOW(app.window));
      break;
    case AppAction::Shortcuts:
      show_shortcuts(GTK_WINDOW(app.window));
      break;
    case AppAction::Quit:
      if (app.window != nullptr) gtk_window_close(GTK_WINDOW(app.window));
      break;
  }
}

Two things to check against the file rather than trusting this listing: whether app.pause_button is a GtkButton (emit clicked) or a GtkToggleButton (use toggle), and whether each of the five toggles named here is really a GtkToggleButton. Use whichever of the two shapes each member actually is; the rule is "do what a click does", never "set the state directly and then call the handler", which would double-apply.

  • Step 2: Install the key controller

At the end of the function that builds the main window (after build_toolbar() and the status row are in place, where the other controllers are attached), add:

  // Bubble phase on purpose: a focused GtkEditable or GtkButton wins its own keys, so typing
  // 'm' into the window entry does not toggle the measurements pane and Space does not
  // double-fire on a focused button.
  GtkEventController* keys = gtk_event_controller_key_new();
  gtk_event_controller_set_propagation_phase(keys, GTK_PHASE_BUBBLE);
  g_signal_connect(keys, "key-pressed", G_CALLBACK(on_app_key_pressed), &app);
  gtk_widget_add_controller(app.window, keys);

and the handler in the anonymous namespace:

/// Looks a key up in the accel table and runs it. Refuses outright while a GtkEditable has
/// focus: every bare-letter accel is also a character somebody may be typing.
gboolean on_app_key_pressed(GtkEventControllerKey*, guint keyval, guint, GdkModifierType state,
                            gpointer user) {
  App* app = static_cast<App*>(user);
  if (app->window == nullptr) return FALSE;
  GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window));
  if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE;

  const Accel* accel = find_accel(keyval, static_cast<unsigned>(state));
  if (accel == nullptr) return FALSE;
  run_app_action(*app, accel->action);
  return TRUE;
}

GTK_IS_EDITABLE catches GtkEntry, GtkSpinButton's entry and GtkSearchEntry alike, which is every text widget this app has.

  • Step 3: Write the keys probe

In the anonymous namespace:

/// Fires a representative accel from each group and reports the state it changed, then
/// checks the GtkEditable refusal. It goes through run_app_action() for the actions and
/// through the real handler for the refusal, because the refusal lives in the handler.
gboolean keys_probe(gpointer user) {
  App* a = static_cast<App*>(user);
  std::string line = "[keys]";

  const auto before_after = [&](const char* name, GtkWidget* button, AppAction action) {
    const int before = button != nullptr
                           ? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0)
                           : -1;
    run_app_action(*a, action);
    const int after = button != nullptr
                          ? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0)
                          : -1;
    char buf[96];
    std::snprintf(buf, sizeof(buf), " %s: %d->%d |", name, before, after);
    line += buf;
  };

  const bool armed_before = a->model->trigger_armed();
  run_app_action(*a, AppAction::TriggerArm);
  char armed[64];
  std::snprintf(armed, sizeof(armed), " a: armed %d->%d |", armed_before ? 1 : 0,
                a->model->trigger_armed() ? 1 : 0);
  line += armed;

  const bool paused_before = a->acq->paused();
  run_app_action(*a, AppAction::PauseToggle);
  char paused[64];
  std::snprintf(paused, sizeof(paused), " space: paused %d->%d |", paused_before ? 1 : 0,
                a->acq->paused() ? 1 : 0);
  line += paused;
  run_app_action(*a, AppAction::PauseToggle);  // leave the run live for the other probes

  before_after("t: trigger panel", a->trigger_toggle, AppAction::TriggerPanel);
  before_after("c: cursors", a->cursors_toggle, AppAction::CursorsPane);
  before_after("m: measures", a->measures_toggle, AppAction::MeasuresPane);
  before_after("g: math", a->math_toggle, AppAction::MathPane);
  before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor);
  before_after("s: side panel", a->sidebar_toggle, AppAction::SidePanel);

  // An accel must be refused while text is being typed. Focus the window entry, send 'm'
  // through the real handler, and require the measurements toggle not to move.
  int refused = -1;
  if (a->window_entry != nullptr) {
    gtk_widget_grab_focus(a->window_entry);
    const int before = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle))
                           ? 1 : 0;
    const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_m, 0,
                                                static_cast<GdkModifierType>(0), a);
    const int after = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle))
                          ? 1 : 0;
    refused = (handled == FALSE && before == after) ? 1 : 0;
  }
  char tail[64];
  std::snprintf(tail, sizeof(tail), " editable focused: 'm' refused=%d\n", refused);
  line += tail;

  std::fprintf(stderr, "%s", line.c_str());
  std::fflush(stderr);
  return G_SOURCE_REMOVE;
}

App::window_entry (src/ui.cpp:109, a GtkEntry the window probe already drives at :6283) is the editable the refusal check focuses. It lives inside the window button's popover (gtk_menu_button_set_popover at :5436), and a widget in an unmapped popover cannot take focus — so the check must pop the popover up first and down again:

  gtk_menu_button_popup(GTK_MENU_BUTTON(a->window_button));
  // ... grab focus, send 'm', read the toggle, compute refused ...
  gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button));

refused=-1 is the probe's own failure sentinel: it means gtk_window_get_focus() did not return a GTK_IS_EDITABLE widget, so the check never ran. Treat it as a failure, not a pass. a->acq->paused() (src/acquisition.hpp:38) and a->model->trigger_armed().load(std::memory_order_relaxed) (src/model.hpp:413) are the accessors the pause and trigger probes already read.

  • Step 4: Register it
  if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) {
    g_timeout_add(static_cast<guint>(std::atoi(env)), keys_probe, &app);
  }
  • Step 5: Build

Run: cmake --build build Expected: warning-free.

  • Step 6: Run the keys probe
python3 tools/mock_udpstreamer.py --duration 25 &
sleep 1
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_KEYS_MS=2500 \
  UDPSCOPE_EXIT_AFTER_MS=10000 \
  ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep -E '\[keys\]|CRITICAL|gaps'
pkill -f 'mock_udpstream[e]r'; pgrep -f 'mock_udpstream[e]r' || echo "mock stopped"

Expected: one [keys] line in which every X->Y pair differs (0->1 or 1->0) and refused=1. A pair that reads 0->0 is an action that did not reach its handler; a refused=0 is the bubble-phase/editable guard not working, which would make the window entry unusable. No Gtk-CRITICAL, and gaps 0.

  • Step 7: Commit
git add src/ui.cpp
git commit -m "feat: keyboard accelerators for panes, trigger, export and the help windows"

Task 10: The two-tier status bar

Files:

  • Create: src/status_text.hpp, src/status_text.cpp
  • Modify: CMakeLists.txt — add src/status_text.cpp
  • Modify: src/ui.cpp — move format_rate/format_elapsed out, fill a StatusSnapshot, set the glance label, build the ⓘ popover once, update it while visible, and replace the two gtk_label_get_text(GTK_LABEL(a->status_label)) reads with App::status_detail

Interfaces:

  • Consumes: nothing from earlier tasks.

  • Produces: StatusSnapshot, format_glance(), format_detail(), and format_rate() / format_elapsed() now declared in src/status_text.hpp. Nothing later consumes them.

  • Step 1: Write the status_text header

Create src/status_text.hpp:

// SPDX-License-Identifier: MIT
#pragma once

#include <cstddef>
#include <cstdint>
#include <string>

namespace udpscope {

/// Everything either tier of the status bar shows, gathered once per frame by the tick so the
/// two cannot disagree. No GTK here on purpose: this is the one part of the status bar that
/// can be reasoned about as text.
struct StatusSnapshot {
  /// One word from the fixed set: "live", "paused", "armed", "capturing", "event held",
  /// "disarmed". It is a word from a *fixed* set because the bar sits directly above the plot
  /// and a field whose width tracked the stream would relayout it every frame.
  const char* state_word = "live";
  /// The long prose the old single-line bar carried, kept for the detail string and the dump.
  std::string state_detail;

  bool connected = false;
  bool have_config = false;
  bool trigger_on = false;

  /// Already collapsed to one figure or a range by the caller, without the "rate " prefix.
  std::string rate_text;
  double stored_rate_hz = 0.0;
  double declared_rate_hz = 0.0;

  /// Pre-formatted by ui.cpp's format_window(), which the window control and the Fit notices
  /// also use, so it stays there rather than moving here.
  std::string window_text;

  std::uint64_t packets = 0;
  std::uint64_t frames = 0;
  std::uint64_t gaps = 0;
  std::size_t signals = 0;
  std::size_t channels = 0;
  int plot1_traces = 0;
  std::size_t plot1_samples = 0;
  double buffer_mb = 0.0;
  double span_s = 0.0;
  std::uint64_t time_stride = 1;
  bool have_event = false;
  double since_event_s = 0.0;
  int reconnects = 0;
};

/// The bar: "live · 10.0 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode and
/// nothing else ever. The badge glyph is a separate widget and is not part of this string.
std::string format_glance(const StatusSnapshot& s);

/// The popover's prose and the exit dump's line, field for field as the old single-line bar
/// wrote it. CLAUDE.md asserts those fields are read out of the dump, so the field names and
/// their order must not change without updating it.
std::string format_detail(const StatusSnapshot& s);

/// A sample rate as "10.0 kS/s". Moved here from ui.cpp, which still calls it.
std::string format_rate(double hz);

/// An elapsed time, stepping to a coarser unit rather than growing digits, so its width
/// cannot track the stream. Moved here from ui.cpp.
std::string format_elapsed(double seconds);

}  // namespace udpscope
  • Step 2: Move the two formatters and write the two new ones

Create src/status_text.cpp. Move the bodies of format_rate (currently src/ui.cpp:881) and format_elapsed (src/ui.cpp:812) here verbatim — do not reimplement them, and do not change their output by a character, because the dump's assertions read what they produce. Then:

// SPDX-License-Identifier: MIT
#include "status_text.hpp"

#include <cstdio>

namespace udpscope {

// ... format_rate() and format_elapsed(), moved verbatim from ui.cpp ...

std::string format_glance(const StatusSnapshot& s) {
  std::string out = s.state_word;
  out += " · ";
  out += s.rate_text.empty() ? "no data" : s.rate_text;
  out += " · ";
  out += s.window_text;
  out += " window";
  if (s.trigger_on) {
    out += " · ";
    if (s.have_event) {
      out += "event " + format_elapsed(s.since_event_s) + " ago";
    } else {
      out += "no event yet";
    }
  }
  return out;
}

std::string format_detail(const StatusSnapshot& s) {
  char buf[1024];
  std::snprintf(buf, sizeof(buf),
                "[%s] %s  %s | rate %s | packets %llu  frames %llu  gaps %llu | %zu signals, "
                "%zu channels | plot 1: %d trace(s), %zu samples | buffer %.1f MB, %.2f s "
                "span, time x%llu",
                s.state_detail.c_str(), s.connected ? "connected" : "waiting",
                s.have_config ? "configured" : "-",
                s.rate_text.empty() ? "--" : s.rate_text.c_str(),
                static_cast<unsigned long long>(s.packets),
                static_cast<unsigned long long>(s.frames),
                static_cast<unsigned long long>(s.gaps), s.signals, s.channels,
                s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
                static_cast<unsigned long long>(s.time_stride));
  std::string out = buf;
  if (s.trigger_on) {
    out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago"
                        : " | no event yet";
  }
  if (s.reconnects > 0) {
    char note[48];
    std::snprintf(note, sizeof(note), " | %d reconnects", s.reconnects);
    out += note;
  }
  return out;
}

}  // namespace udpscope

Before building, diff this against the live std::snprintf at src/ui.cpp:5216 and fix any divergence in the format string: that line is the contract the dump's assertions were written against. Two differences are deliberate and must be preserved exactly as written above. The live code's rate local arrives already carrying its "rate " prefix and reads "rate --" when no channel has a figure; rate_text carries neither prefix nor sentinel, because format_glance() tests it for emptiness to print "no data" — so format_detail() writes the literal rate and substitutes "--" itself. And the live string ends with the since/reconnects_note suffixes spliced into the snprintf arguments; here they are appended afterwards, producing the same bytes in the same order.

Add src/status_text.cpp to add_executable in CMakeLists.txt, before src/shortcuts.cpp.

  • Step 3: Fill the snapshot in tick()

In src/ui.cpp, add #include "status_text.hpp" and delete the format_rate and format_elapsed definitions (the header now declares them, and the three remaining call sites — :1806, :5073-5074, :5190-5191 — keep compiling unchanged).

Replace the status composition block (src/ui.cpp:5124-5234) so that it fills a snapshot rather than a string. Keep every existing computation that block already performs, unchanged and in place: the mode prose (:5125-5144), the badge block (:5146-5167), the bound/samples loop (:5169-5177) and the rate collapse (:5181-5192). Delete only the three pieces that formatted the old single line — the since string (:5197-5204), the reconnects_note buffer (:5208-5213) and the snprintf into status (:5215-5226) — since format_detail() now produces all three. Then fill the snapshot from the locals those blocks leave behind; every right-hand side below is an expression that already exists in tick() at this point:

  StatusSnapshot snap;
  snap.state_detail = mode;                 // :5125-5144's prose, unchanged
  snap.state_word = state_word;             // the one new computation, see below
  snap.connected = st.connected.load();
  snap.have_config = st.have_config.load();
  snap.trigger_on = trigger_on;             // the local :5126 branches on
  snap.rate_text = rate;                    // the collapse's output, prefix dropped, see below
  snap.stored_rate_hz = app->stored_rate.empty() ? 0.0 : app->stored_rate.front();
  snap.declared_rate_hz = app->declared_rate.empty() ? 0.0 : app->declared_rate.front();
  snap.window_text = format_window(app->rows->time_window());
  snap.packets = st.packets.load();
  snap.frames = st.frames.load();
  snap.gaps = st.gaps.load();
  snap.signals = signal_count;              // the local the old snprintf passed
  snap.channels = channel_count;
  snap.plot1_traces = bound;                // from the :5169-5177 loop
  snap.plot1_samples = samples;             // from the same loop
  snap.buffer_mb = double(model.config().buffer_mb);
  snap.span_s = span_s;
  snap.time_stride = time_stride;           // already std::uint64_t at :5096
  snap.have_event = app->last_latch_us != 0;
  snap.since_event_s = snap.have_event
                           ? double(g_get_monotonic_time() - app->last_latch_us) / 1.0e6
                           : 0.0;
  snap.reconnects = static_cast<int>(st.reconnects.load(std::memory_order_relaxed));

  const std::string glance = format_glance(snap);
  app->status_detail = format_detail(snap);
  if (app->status_detail.size() > app->status_detail_max) {
    app->status_detail_max = app->status_detail.size();
  }
  if (glance.size() > app->glance_max_chars) app->glance_max_chars = glance.size();

  if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) {
    const std::string with_notice = app->notice + "   |   " + glance;
    gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str());
  } else {
    app->notice.clear();
    gtk_label_set_text(GTK_LABEL(app->status_label), glance.c_str());
  }
  gtk_widget_set_tooltip_text(app->status_label, app->status_detail.c_str());
  if (app->status_popover != nullptr &&
      gtk_widget_get_visible(app->status_popover) == TRUE) {
    update_status_popover(*app, snap);
  }

Two small edits the fill above depends on. The rate collapse at src/ui.cpp:5181-5192 currently writes its own "rate " prefix, and its only consumer was the snprintf this step deletes — format_detail() writes the prefix now and format_glance() tests the string for emptiness, so drop the prefix from both branches: the initialiser becomes std::string rate; (empty, not "rate --") and the assignment becomes rate = hi - lo <= 0.01 * hi ? format_rate(hi) : format_rate(lo) + ".." + format_rate(hi);. And app->rows is a pointer in tick() (app->rows->row(0) at :5172), hence app->rows->time_window() above.

The notice prefix and the notice_until_us expiry keep their existing behaviour exactly — the notice is prefixed to the glance line now, which is where the user is looking. The existing rate tooltip is superseded by the detail tooltip; delete the rate_tip computation and its gtk_widget_set_tooltip_text at :5234 rather than setting the tooltip twice.

state_word is the one new computation. Derive it from the same conditions the mode prose already branches on, in the same order, so the two cannot disagree:

  const char* state_word = "live";
  if (display == DisplayMode::Capturing) {
    state_word = "capturing";
  } else if (display == DisplayMode::Held) {
    state_word = "event held";
  } else if (trigger_on && !armed) {
    state_word = "disarmed";
  } else if (display == DisplayMode::Waiting) {
    state_word = "armed";
  } else if (paused) {
    state_word = "paused";
  }

Use the file's own names for display, armed, paused and trigger_on — the mode block immediately above already has all four in scope. The six words are the complete fixed set; a seventh would break the "layout must not depend on content" rule the bar is built on.

  • Step 4: Add the App members

Beside the existing status members (status_label at :96, status_badge at :99):

  /// The detail string the popover and the exit dump read. The dump used to read the label,
  /// which no longer carries these fields.
  std::string status_detail;
  std::size_t status_detail_max = 0;
  /// The widest glance line this run produced. The bar sits above the plot, so this is the
  /// "layout must not depend on content" reading for the status row.
  std::size_t glance_max_chars = 0;
  /// The last glance line, for the exit dump's `status bar:` line — the dump runs after the
  /// window is destroyed, so it cannot ask the label.
  std::string dbg_glance_last;

  GtkWidget* status_info_button = nullptr;  ///< the ⓘ
  GtkWidget* status_popover = nullptr;
  /// The popover's eleven labels: four row headings and seven value lines. Built once; the
  /// tick sets their text. A rebuild here would be a per-frame relayout of a visible widget.
  GtkWidget* popover_label[11] = {};
  int popover_rebuilds = 0;
  • Step 5: Build the popover once

In the status row construction (src/ui.cpp:5632-5651), after the status label is packed, add the ⓘ button at the row's end and build its popover:

  app.status_popover = gtk_popover_new();
  GtkWidget* detail_grid = gtk_grid_new();
  gtk_grid_set_row_spacing(GTK_GRID(detail_grid), 2);
  gtk_grid_set_column_spacing(GTK_GRID(detail_grid), 16);
  gtk_widget_set_margin_start(detail_grid, 10);
  gtk_widget_set_margin_end(detail_grid, 10);
  gtk_widget_set_margin_top(detail_grid, 8);
  gtk_widget_set_margin_bottom(detail_grid, 8);

  // Row headings at indices 0..3, value lines at 4..10, in the order format_detail's fields
  // appear. Built once: update_status_popover() only ever calls gtk_label_set_text().
  static const char* const kHeadings[] = {"Stream", "Buffer", "Plot 1", "Reconnects"};
  const int kHeadingRow[] = {0, 3, 5, 6};
  for (int i = 0; i < 4; ++i) {
    GtkWidget* heading = gtk_label_new(nullptr);
    char markup[64];
    std::snprintf(markup, sizeof(markup), "<b>%s</b>", kHeadings[i]);
    gtk_label_set_markup(GTK_LABEL(heading), markup);
    gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
    gtk_label_set_yalign(GTK_LABEL(heading), 0.0f);
    gtk_grid_attach(GTK_GRID(detail_grid), heading, 0, kHeadingRow[i], 1, 1);
    app.popover_label[i] = heading;
  }
  for (int i = 0; i < 7; ++i) {
    GtkWidget* value = gtk_label_new("");
    gtk_label_set_xalign(GTK_LABEL(value), 0.0f);
    gtk_grid_attach(GTK_GRID(detail_grid), value, 1, i, 1, 1);
    app.popover_label[4 + i] = value;
  }
  gtk_popover_set_child(GTK_POPOVER(app.status_popover), detail_grid);

  app.status_info_button = gtk_menu_button_new();
  gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app.status_info_button),
                                "help-about-symbolic");
  gtk_menu_button_set_popover(GTK_MENU_BUTTON(app.status_info_button), app.status_popover);
  gtk_widget_set_tooltip_text(app.status_info_button, "Stream and buffer detail");
  gtk_widget_set_has_tooltip(app.status_info_button, TRUE);

Pack app.status_info_button at the end of the status row so it sits at the far right, with the status label expanding to fill the space before it (the label is already ellipsized, which is what keeps its text out of the row's minimum width). The value rows and the heading rows share grid rows 0, 3, 5 and 6: the headings span nothing extra, the values occupy rows 0-6, so Stream names rows 0-2, Buffer rows 3-4, Plot 1 row 5 and Reconnects row 6.

Then the updater, in the anonymous namespace:

/// Sets the popover's seven value labels. Never creates a widget: `popover_rebuilds` exists to
/// assert that, because the popover is visible when this runs and a rebuild would relayout it.
void update_status_popover(App& app, const StatusSnapshot& s) {
  if (app.popover_label[4] == nullptr) return;
  char buf[256];

  std::snprintf(buf, sizeof(buf), "%s, %s", s.connected ? "connected" : "waiting",
                s.have_config ? "configured" : "no config");
  gtk_label_set_text(GTK_LABEL(app.popover_label[4]), buf);

  std::snprintf(buf, sizeof(buf), "packets %llu · frames %llu · gaps %llu",
                static_cast<unsigned long long>(s.packets),
                static_cast<unsigned long long>(s.frames),
                static_cast<unsigned long long>(s.gaps));
  gtk_label_set_text(GTK_LABEL(app.popover_label[5]), buf);

  std::snprintf(buf, sizeof(buf), "%zu signals · %zu channels", s.signals, s.channels);
  gtk_label_set_text(GTK_LABEL(app.popover_label[6]), buf);

  std::snprintf(buf, sizeof(buf), "%.1f MB · %.0f s span · time x%llu", s.buffer_mb, s.span_s,
                static_cast<unsigned long long>(s.time_stride));
  gtk_label_set_text(GTK_LABEL(app.popover_label[7]), buf);

  const std::string stored = format_rate(s.stored_rate_hz);
  const std::string wire = format_rate(s.declared_rate_hz);
  std::snprintf(buf, sizeof(buf), "stored %s (wire %s)", stored.c_str(), wire.c_str());
  gtk_label_set_text(GTK_LABEL(app.popover_label[8]), buf);

  std::snprintf(buf, sizeof(buf), "%d traces · %zu samples", s.plot1_traces, s.plot1_samples);
  gtk_label_set_text(GTK_LABEL(app.popover_label[9]), buf);

  std::snprintf(buf, sizeof(buf), "%d", s.reconnects);
  gtk_label_set_text(GTK_LABEL(app.popover_label[10]), buf);
}
  • Step 6: Re-point the two dump reads

At src/ui.cpp:5730, replace:

  a->dbg_mode_live = gtk_label_get_text(GTK_LABEL(a->status_label));

with:

  a->dbg_mode_live = a->status_detail;

and the same at :5775 for a->dbg_mode_triggered. The label no longer carries packets, gaps, plot 1: or the buffer figures, so without this the dump's documented assertions would silently start reading a four-field glance line.

  • Step 7: Add the dump line

In the exit dump sequence (near src/ui.cpp:7335, beside mode with trigger on:), add:

  std::printf("[udpscope] status bar: glance=[● %s] max_glance_chars=%zu detail_fields=11 "
              "popover_rebuilds=%d\n",
              app.dbg_glance_last.c_str(), app.glance_max_chars, app.popover_rebuilds);

dbg_glance_last is the member added in Step 4; assign it in the tick beside status_detail (app->dbg_glance_last = glance;, after the format_glance() call in Step 3), so the dump has the last glance line after the window is gone — asking GTK for a label's text once the window is destroyed is four criticals. detail_fields is 11 because the popover's grid holds eleven labels — four headings and seven value lines — and the figure is a literal rather than a count so a label added without a thought about layout changes the dump.

  • Step 8: Build

Run: cmake --build build Expected: warning-free. -Wunused-function on the old format_rate/format_elapsed would mean a stale copy was left behind in ui.cpp; there must be exactly one definition of each.

  • Step 9: Verify the bar, the popover and the dump compatibility
python3 tools/mock_udpstreamer.py --duration 25 &
sleep 1
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_EXIT_AFTER_MS=12000 \
  ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | \
  grep -E 'status bar:|mode with trigger on:|mode after go-live:|badge frames|x-desync|axes.w changes|gaps'
pkill -f 'mock_udpstream[e]r'; pgrep -f 'mock_udpstream[e]r' || echo "mock stopped"

Expected:

  • status bar: glance=[● live · 10.0 kS/s · 2 s window] max_glance_chars=<N> detail_fields=11 popover_rebuilds=0 with N under about 60 and popover_rebuilds=0.
  • mode with trigger on: and mode after go-live: still carrying every field they carry today — packets, frames, gaps, the signal and channel counts, plot 1:, buffer … time x…. A short four-field value there means Step 6 was not applied.
  • badge frames: lit=… dim=… idle=… with both lit and dim non-zero (the badge was untouched and must still blink).
  • x-desync 0 and axes.w changes: at 0 or a handful near the start — the status row changed shape, and this is the check that it did not start driving the plot's layout.
  • gaps 0.

Click-free verification of the popover is not possible headlessly, so popover_rebuilds=0 plus detail_fields=11 is the state assertion; the labels' content is covered by format_detail() carrying the same figures into the dump.

  • Step 10: Commit
git add src/status_text.hpp src/status_text.cpp src/ui.cpp CMakeLists.txt
git commit -m "feat: two-tier status bar, one glance line with the detail behind an info popover"

Task 11: Documentation and the full verification pass

Files:

  • Modify: CLAUDE.md — the three new probes, the new dump line, the new invariants
  • Modify: README.md — the four features and the ctrl/alt deviation

Interfaces:

  • Consumes: every earlier task.

  • Produces: nothing. This is the gate.

  • Step 1: Document the probes in CLAUDE.md

In the Verification section, add four entries in the style of the ones around them (a line of probe output, then what in it is an assertion and why):

  • [help] pages=14 parsed=14 shortest='…' N B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=…; closed ok (stderr, UDPSCOPE_PROBE_HELP_MS) — parsed must equal pages and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window looks fine and only this count notices. settings= must be a real path. closed ok with no Gtk-CRITICAL is as much a crash test of three windows built, presented and closed in one run as it is a state check — each closes with gtk_window_close(), never gtk_window_destroy(), so its close-request handler runs and nulls the pointer.

  • [keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel 0->1 | … | editable focused: 'm' refused=1 (stderr, UDPSCOPE_PROBE_KEYS_MS) — every X->Y pair must differ and refused must be 1. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a GtkEditable has focus makes the window entry unusable. A pair reading 0->0 is an action whose dispatch does not reach the handler a click reaches.

  • [zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1 (stderr, UDPSCOPE_PROBE_ZOOMKEYS_MS) — both _held must be 1 and both zooms must still happen. Run it inside a pinned state, where a frame cannot re-frame the other axis for its own reasons: UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200. A _held=0 is the modifier not reaching the plotter's controller; an unchanged range on the restricted axis is the opposite failure, the modifier swallowing the key.

  • status bar: glance=[● live · 10.0 kS/s · 2 s window] max_glance_chars=N detail_fields=11 popover_rebuilds=0 (exit dump) — max_glance_chars must stay small (under about 60) and popover_rebuilds must be 0. The status row is directly above the plot, so this is the "layout must not depend on content" rule read from the bar's own side: the state field is one word from a fixed set of six, and the only field trigger mode adds is the elapsed time, which steps units rather than growing digits. detail_fields is the popover's label count, a literal, so adding a label without thinking about layout shows up here.

  • Step 2: Document the new invariants in CLAUDE.md

Add to Invariants worth knowing before editing:

  • The status bar is two tiers, and the dump reads the detail one. format_glance() writes the bar and format_detail() writes both the popover's prose and App::status_detail, which is what exit_probe_step3/exit_probe_step2 copy into dbg_mode_live/dbg_mode_triggered. Those two used to read the label, and the label no longer carries packets, gaps, plot 1: or the buffer figures — so a gtk_label_get_text() there would silently reduce every documented mode …: assertion to four fields. The glance line's state field is one word from a fixed set (live, paused, armed, capturing, event held, disarmed) for the layout reason above; a seventh word, or a field whose width tracks the stream, breaks the row the plot is laid out under.

  • The ⓘ popover's widgets are built once and only their text is set. It is visible when the tick updates it, so a rebuild is a per-frame relayout of a widget on screen. update_status_popover() only ever calls gtk_label_set_text(), and popover_rebuilds in the dump is the assertion.

  • Every help and shortcut window closes with gtk_window_close(). gtk_window_destroy() does not emit close-request, so the handler that nulls the file-scope pointer never runs and the next open presents a dead widget — the same trap the math dialog fell into. One GtkWidget* per window, one shared on_window_close handler taking the slot as its user data.

  • GtkShortcutsWindow is deprecated as of GTK 4.18 and must not be used. The overview is a GtkGrid of rows generated from the two tables in src/shortcuts.cpp, grouped by consecutive equal section — which is why each table keeps its sections contiguous. GtkAboutDialog is not deprecated and is used as it stands.

  • find_accel() masks the modifiers before comparing. Lock, Num-Lock and the pointer buttons all arrive in GdkModifierType, so an unmasked compare matches no table row at all once Caps Lock is on.

  • A help page is one .txt, one <file> line and one kPages row. The bodies are Pango markup in a second GResource (--c-name=udpscope_help, so its generated symbols cannot collide with the icons'), validated with pango_parse_markup() at load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from parsed.

  • build_info.cpp is generated, never committed. cmake/write_build_info.cmake runs at configure time (so add_executable can name the file) and again on every build through udpscope_build_info (so the commit and the timestamp belong to the binary that is running). git absent leaves kCommit at "unknown" and the build succeeds.

  • The keyboard zoom's modifiers are not the wheel's. +/- with Ctrl restricts to x and with Alt to y, while the wheel keeps upstream's shift = x / ctrl = y. Shift is unusable on the keyboard path: key_from() folds '+' and '=' onto kKeyZoomIn because + is shift+= on common layouts, so a bare zoom-in already arrives with Mod::Shift set. Ctrl+Alt together zooms neither, which needs no special case. The guards are deps/plotter's own ctrl_plus_zooms_only_x / alt_plus_zooms_only_y plus UDPSCOPE_PROBE_ZOOMKEYS_MS.

  • Step 3: Document the features in README.md

Add to the feature narrative, in the file's existing voice:

  • The About dialog (version, commit, build timestamp, the settings directory and this session's file).

  • The shortcut overview, with the full key table and the note that the keyboard zoom's Ctrl/Alt is a deliberate deviation from the shift convention the wheel uses, and why.

  • The 14-page help window, and that the pages are compiled into the binary because udpscope is run straight out of the build tree with no install step.

  • The two-tier status bar: what moved off the bar, what the ⓘ popover shows, and that the exit dump still prints the full detail string so the documented assertions did not change.

  • Step 4: Clean from-scratch build

cmake -S . -B /tmp/udpscope-clean -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo \
  && cmake --build /tmp/udpscope-clean 2>&1 | grep -E 'warning|error' | grep -v deps/ || echo "no warnings in src/"

Expected: no warnings in src/.

  • Step 5: The plotter's own suite
cmake --build deps/plotter/build/debug && xvfb-run -a deps/plotter/build/debug/tests/plot_tests 2>&1 | tail -5

Expected: 0 failed.

  • Step 6: The three new probes, one run at a time
python3 tools/mock_udpstreamer.py --duration 30 &
sleep 1
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_HELP_MS=2000 \
  UDPSCOPE_PROBE_KEYS_MS=3500 UDPSCOPE_EXIT_AFTER_MS=12000 \
  ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep -E '\[help\]|\[keys\]|CRITICAL'
pkill -f 'mock_udpstream[e]r'; pgrep -f 'mock_udpstream[e]r' || echo "mock stopped"

Expected: pages=14 parsed=14, shortcuts: app=12 plot=13, closed ok; every [keys] pair differing and refused=1; no Gtk-CRITICAL.

python3 tools/mock_udpstreamer.py --duration 25 &
sleep 1
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_PAUSE_MS=1500 \
  UDPSCOPE_PROBE_ZOOMKEYS_MS=2200 UDPSCOPE_EXIT_AFTER_MS=10000 \
  ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | grep -E '\[zoomkeys\]|\[pause-probe\]'
pkill -f 'mock_udpstream[e]r'; pgrep -f 'mock_udpstream[e]r' || echo "mock stopped"

Expected: y_held=1 and x_held=1, both ranges narrowed, and the pause probe's own assertions unchanged (ring_produced growing, event_size non-zero while paused).

  • Step 7: The regression net on the fast mock
python3 tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --duration 45 &
sleep 1
xvfb-run -a env UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 \
  UDPSCOPE_PROBE_CURSORS_MS=3500 UDPSCOPE_EXIT_AFTER_MS=30000 \
  ./build/udpscope --server 127.0.0.1 --port 8000 2>&1 | \
  grep -E 'status bar:|mode with trigger on:|mode after go-live:|badge frames|x-desync|axes.w changes|trace ink:|toolbar icons:|gaps|frame cost'
pkill -f 'mock_udpstream[e]r'; pgrep -f 'mock_udpstream[e]r' || echo "mock stopped"

Expected: status bar: with popover_rebuilds=0 and a small max_glance_chars; the two mode …: lines carrying every field they carried before this branch; badge frames with lit and dim both non-zero; x-desync 0; axes.w changes 0 or a handful near the start; trace ink: … 0 collapsed, 0 staircase; every toolbar icons: entry (ok); gaps 0.

  • Step 8: Clean up and commit
rm -rf /tmp/udpscope-clean
pgrep -f 'mock_udpstream[e]r' || echo "no mocks running"
git add CLAUDE.md README.md
git commit -m "docs: help, shortcuts, About and the two-tier status bar"
  • Step 9: Finish the branch

Use superpowers:finishing-a-development-branch.