From 82f91d00c04a05d46a1299390fb069defdde0495 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Sun, 4 Oct 2026 11:48:06 +0200 Subject: [PATCH] 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 --- .../plans/2026-10-04-help-and-status.md | 2797 +++++++++++++++++ 1 file changed, 2797 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-04-help-and-status.md diff --git a/docs/superpowers/plans/2026-10-04-help-and-status.md b/docs/superpowers/plans/2026-10-04-help-and-status.md new file mode 100644 index 0000000..ab5f36d --- /dev/null +++ b/docs/superpowers/plans/2026-10-04-help-and-status.md @@ -0,0 +1,2797 @@ +# 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`: + +```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`: + +```cpp +// 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: + +```cmake +project(udpscope LANGUAGES C CXX) +``` + +with: + +```cmake +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: + +```cmake +# --- 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`: + +```cmake +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: + +```cmake +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: + +```cpp +/// 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()`: + +```cpp +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`: + +```cpp +// SPDX-License-Identifier: MIT +#pragma once + +#include + +#include + +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`: + +```cpp +// SPDX-License-Identifier: MIT +#include "help_window.hpp" + +#include + +#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(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: + +```cpp +#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: + +```cpp + 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()`: + +```cpp +void on_about_clicked(GtkButton*, gpointer user) { + App* app = static_cast(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: + +```bash +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** + +```bash +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: + +```cpp +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::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::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: + +```cpp + // 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** + +```bash +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: + +```cpp +/// 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(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(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::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::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: + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_ZOOMKEYS_MS")) { + g_timeout_add(static_cast(std::atoi(env)), zoomkeys_probe, &app); + } +``` + +- [ ] **Step 3: Build** + +Run: `cmake --build build` +Expected: no warnings. + +- [ ] **Step 4: Run it inside a pause** + +```bash +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** + +```bash +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 `` 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 `&`, `<`, `>`. 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`: + +``` +What udpscope is + +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. + +How to reach it + +Start the app with a source (udpscope --server 127.0.0.1 --port 8000), or start it +with no --server and pick one from the chooser. The signals arrive disabled; turn +one on in the side panel (s) and drag it onto a row. + +Worth knowing + +• 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. + +Example + +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 ● live · 10.0 kS/s · 2 s window. +``` + +Create `src/help/02-source.txt`: + +``` +Choosing a source + +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. + +How to reach it + +Run with --server and the app connects straight away. Run without +--server and a chooser appears first: the sessions udpscope has stored, newest +first, plus a New host… 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. + +Worth knowing + +• The list is the sessions that named a signal. 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 (host_port.toml) cannot + carry them. +• --multicast still needs --server: the CONFIG message is fetched from the + server, not from the group. +• The × beside a row deletes that session file. Nothing else is removed. + +Example + +udpscope with two stored sessions lists +127.0.0.1:8000 plots=1 signals=3 and +10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9. +Activating the first reports [sources] picked 127.0.0.1:8000 and continues into +the scope with that session's layout already loaded. +``` + +Create `src/help/03-signals.txt`: + +``` +Signals and calibration + +The source's CONFIG message describes its signals: a name, a unit and a type. udpscope turns +each enabled signal into a channel — 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. + +How to reach it + +The side panel (s) lists every signal with a switch. The Signal list item in +the overflow menu opens the editor where the unit and the calibration live. + +Worth knowing + +• 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 at ingest, 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 timestamp, 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 signal, 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. + +Example + +A source declaring time, sine, ramp and noise yields three +plottable signals. Enable all three and the exit dump reads 3 signals, 3 channels; +time is not among them. +``` + +Create `src/help/04-layout.txt`: + +``` +Rows, layout and drag & drop + +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. + +How to reach it + +Drag a signal's chip out of the side panel (s) and drop it on a row to add a trace. +Press l, or pick Layout editor from the overflow menu, to add and remove rows: +a × appears at the top left of each row and a + 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. + +Worth knowing + +• A drop adds 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 with their contents. 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 name, 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. + +Example + +With three traces on row 1, pressing l shows 3 buttons (one ×, two ++). Clicking the + above row 1 gives 2 rows and 5 buttons, with the three +traces now on row 2 — the layout probe reports exactly this as +rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]. +``` + +- [ ] **Step 2: Write the GResource manifest** + +Create `src/help/udpscope-help.gresource.xml`: + +```xml + + + + + 01-overview.txt + 02-source.txt + 03-signals.txt + 04-layout.txt + + +``` + +Tasks 5, 6 and 7 add their `` 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: + +```cmake +# --- 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 + ${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()`: + +```cpp +/// 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 ` 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`): + +```cpp +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 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(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 "This page is missing from the build."; + } + gsize size = 0; + const char* data = static_cast(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 "This page could not be displayed: its markup is malformed."; + } + *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: + +```cpp +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), "%s", 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 ` and `#include ` 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: + +```cpp + 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: + +```cpp +void on_help_clicked(GtkButton*, gpointer user) { + App* app = static_cast(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: + +```cpp +/// 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(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`: + +```cpp +/// 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`: + +```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: + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) { + g_timeout_add(static_cast(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** + +```bash +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** + +```bash +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 `` 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: + +``` +Time window + +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 this window. + +How to reach it + +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. + +Worth knowing + +• A window too long to store at the wire rate is decimated, not refused. The shared clock + takes a power-of-two stride, and each group of samples is averaged 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 mean, 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 stored (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. + +Example + +1 MS/s with 200 MB of buffer. A 2 s window stores every sample (time x1). Widen it to +600 s and the stride becomes x16: 625 S/s stored, anti-aliased at 312.5 Hz. A 2 kHz +sine seen through that window reads y=[-0.059, 0.079] — attenuated to about 6%, not +folded down to a full-amplitude 125 Hz one. +``` + +Create `src/help/06-navigate.txt`: + +``` +Navigating the plot + +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. + +How to reach it + +Wheel zooms both axes · Shift+wheel zooms x only · Ctrl+wheel zooms y only. +Drag pans · Ctrl+drag is a box zoom on x · Alt+drag a box zoom on y. ++ and - zoom both axes; Ctrl with them restricts to x, Alt to y. +Arrow keys pan, f fits, Escape cancels a gesture in progress. +The full table is in the shortcut overview (Ctrl+/). + +Worth knowing + +• The keyboard zoom's modifiers are not the wheel's, and that is deliberate: on common + layouts + is shift+=, 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 pinned 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 in 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. + +Example + +Pause a live view, then press Ctrl++: x narrows and y does not move. Press +Alt++: y narrows and x does not. Eight doublings into a held 2 s capture, the trace +still reaches both margins — the zoom probe reports gap_px and end_px within +a pixel or two at every step. +``` + +Create `src/help/07-pause.txt`: + +``` +Pause and play + +Pause freezes the display, 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. + +How to reach it + +The pause button in the toolbar, or Space. Press it again to resume, which rebinds the +live ring and puts the window back on the newest data. + +Worth knowing + +• 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 snapshot's own stamps, 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. + +Example + +Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads +[-1.40 .. 0.00] rather than [-2.00 .. 0.00] with 0.6 s of blank. The pause +probe reports ring_produced still growing across the pause and +event_size non-zero exactly while paused. +``` + +Create `src/help/08-trigger.txt`: + +``` +Trigger + +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. + +How to reach it + +The trigger button in the toolbar (t) reveals the trigger panel: the signal, the level, +the edge (rising, falling, either), the mode (Normal or Single) and the pre-trigger share. +Arm starts watching; a arms and disarms from the keyboard. + +Worth knowing + +• Arming enables 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 display window, 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: armed (watching, + nothing drawn yet), capturing (the edge was accepted and the post window is filling + in view, with the marker at x = 0), event held (the frozen capture) and + disarmed. 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 during 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 stored 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. + +Example + +A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans +[-1.000 .. 1.000] with n=20001 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 `04-layout.txt`: + +```xml + 05-window.txt + 06-navigate.txt + 07-pause.txt + 08-trigger.txt +``` + +- [ ] **Step 3: Add them to the registry** + +In `src/help_window.cpp`'s `kPages`, after the `04-layout` row: + +```cpp + {"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** + +```bash +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** + +```bash +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 `` 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`: + +``` +Cursors + +Cursors read values off the traces. There are two kinds: one shared vertical pair that +every row carries at the same time, and horizontal rulers, one pair per row, that belong +to that row alone. + +How to reach it + +The cursors button in the toolbar, or c. The pane below the plot shows the readings and +owns a Reset button. Drag a marker to move it. The mode dropdown offers four modes: +vertical (the shared time pair), horizontal (the per-row rulers), +both, and mix. + +Worth knowing + +• The vertical pair is one time for the whole figure, so moving it on any row moves it on + all of them. That is what makes dt meaningful across rows. +• A ruler belongs to its row and nothing shares it, so two rows' rulers are independent. +• mix is the reading mode: one line per bound 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 --. With only one marker over data the row + reads partial: a difference between a number and nothing is not a number. +• In mix 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. horizontal has no + time row, by design. + +Example + +Three traces in mix mode with both markers over data: 3 lines and 6 crossing marks, +each line reading like sine 0.31 V -0.84 V -1.15 V. Move the second marker past the +newest sample and the third column reads --. +``` + +Create `src/help/10-measurements.txt`: + +``` +Measurements + +A measurement is a statistic of the visible window of one signal, recomputed as that +window moves. The pane lists them with their values; each can also draw its result on the plot. + +How to reach it + +The measurements button in the toolbar, or m. The pane's + adds one; clicking +a row opens the dialog where the signal, the type, the threshold share and the on-plot marks are +set, with Save and Delete. + +Worth knowing + +• The types are average, top, bottom, min, max, peak to peak, amplitude, pulse duration, period + and frequency. top and bottom 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 level mark is a ruler + at a value, a time mark is a marker at a position. An average draws one level mark; a + max draws a level mark and 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 + stepped. The number and the ruler come from the same pass, so they always agree. +• An invalid pass keeps its level marks and drops its time 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 invalid / -- rather than a plausible + invented number. Frequency over a window holding one edge is the usual case. +• frequency is reported in Hz and period/pulse duration 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. + +Example + +On a 1 Hz ±1 sine: average=0, top=0.636620, bottom=-0.636620, +max=1, min=-1, peak to peak=2, amplitude=1, +pulse duration=0.5 s (duty 0.5), period=1 s, frequency=1 Hz. Over a +2 M-sample window those two passes read about 0.1% of the samples. +``` + +Create `src/help/11-math.txt`: + +``` +Math channels + +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. + +How to reach it + +The math button in the toolbar, or g. The pane's + 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. + +Worth knowing + +• The graph is evaluated as an ordered list, so a node may only read ones above it. A + cycle is impossible by construction rather than rejected by a check. +• A derived channel is 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 stored 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 acquired 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 last 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. + +Example + +[sine] → [gain x2] gives a channel reading y=[-2, 2]. 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 `08-trigger.txt`: + +```xml + 09-cursors.txt + 10-measurements.txt + 11-math.txt +``` + +- [ ] **Step 3: Add them to the registry** + +In `src/help_window.cpp`'s `kPages`, after the `08-trigger` row: + +```cpp + {"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** + +```bash +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** + +```bash +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 `` 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`: + +``` +Saving and export + +Export writes the visible range of every plotted trace to a file: NumPy .npy or +.csv, chosen by the extension you type. + +How to reach it + +Export visible range… in the overflow menu, or Ctrl+E. A file chooser asks where; +the format follows the path's extension. + +Worth knowing + +• 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 stored samples, 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 that — read the status bar's ⓘ popover + for the figure before trusting a sample count. +• Two time columns. time is the display timeline (relative to the trigger edge while an + event is held) and stream_time 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 mid-capture 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. + +Example + +A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns +(time, stream_time, three signals). The .npy is +64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols) bytes. Read it back with +pd.DataFrame(np.load(path)). +``` + +Create `src/help/13-sessions.txt`: + +``` +Sessions and settings + +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 is the settings. + +How to reach it + +Nothing to open — the file is written as you work. Its path is in the About dialog, and the +directory is $XDG_CONFIG_HOME/udpscope, or ~/.config/udpscope when that is +unset. Each file is named <host>_<port>.toml. + +Worth knowing + +• 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 name 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 not write: a configured session is never overwritten by a run that + configured nothing. +• The multicast group and the interface live inside 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 × does + exactly that. + +Example + +Connect to 127.0.0.1:8000, drop three signals on row 1 and set a 2 s window. +~/.config/udpscope/127.0.0.1_8000.toml now holds rows=1 window_s=2 with +signals = ["sine", "ramp", "noise", ""], and the next run reports +layout loaded from …/127.0.0.1_8000.toml with those signals already acquired. +``` + +Create `src/help/14-status.txt`: + +``` +Status bar and diagnostics + +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. + +How to reach it + +The bar is always visible under the plot. Click ⓘ for the detail popover. + +Worth knowing + +• 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: live, paused, armed, + capturing, event held, disarmed. 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. +• gaps 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. +• reconnects 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 — connected, but no new frames in the last second — is that same + situation seen from the other side. Pointing the app at a port where nothing answers produces + it too. +• stored versus wire in the popover is the decimation. They differ exactly + when the window is too long to store at full rate. + +Example + +The bar reads ● live · 10.0 kS/s · 2 s window. Behind ⓘ: +Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3 channels, +Buffer 200.0 MB · 1677 s span · time x1 / stored 10.0 kS/s (wire 10.0 kS/s), +Plot 1 3 traces · 195000 samples, Reconnects 0. +``` + +- [ ] **Step 2: Add them to the manifest** + +In `src/help/udpscope-help.gresource.xml`, after `11-math.txt`: + +```xml + 12-export.txt + 13-sessions.txt + 14-status.txt +``` + +- [ ] **Step 3: Add them to the registry** + +In `src/help_window.cpp`'s `kPages`, after the `11-math` row: + +```cpp + {"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** + +```bash +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** + +```bash +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`: + +```cpp +// 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`: + +```cpp +// SPDX-License-Identifier: MIT +#include "shortcuts.hpp" + +#include + +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(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(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: + +```cpp +/// 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: + +```cpp +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), "%s", 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), "%s", 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()`: + +```cpp + 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: + +```cpp + 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: + +```cpp +void on_shortcuts_clicked(GtkButton*, gpointer user) { + App* app = static_cast(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: + +```cpp + 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** + +```bash +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** + +```bash +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: + +```cpp +/// 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: + +```cpp + // 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: + +```cpp +/// 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(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(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: + +```cpp +/// 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(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(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: + +```cpp + 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** + +```cpp + if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) { + g_timeout_add(static_cast(std::atoi(env)), keys_probe, &app); + } +``` + +- [ ] **Step 5: Build** + +Run: `cmake --build build` +Expected: warning-free. + +- [ ] **Step 6: Run the keys probe** + +```bash +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** + +```bash +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`: + +```cpp +// SPDX-License-Identifier: MIT +#pragma once + +#include +#include +#include + +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: + +```cpp +// SPDX-License-Identifier: MIT +#include "status_text.hpp" + +#include + +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(s.packets), + static_cast(s.frames), + static_cast(s.gaps), s.signals, s.channels, + s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s, + static_cast(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: + +```cpp + 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(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: + +```cpp + 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`): + +```cpp + /// 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: + +```cpp + 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), "%s", 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: + +```cpp +/// 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(s.packets), + static_cast(s.frames), + static_cast(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(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: + +```cpp + a->dbg_mode_live = gtk_label_get_text(GTK_LABEL(a->status_label)); +``` + +with: + +```cpp + 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: + +```cpp + 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** + +```bash +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= 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** + +```bash +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 `` 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** + +```bash +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** + +```bash +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** + +```bash +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`. + +```bash +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** + +```bash +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** + +```bash +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`.