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

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

2798 lines
127 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <gtk/gtk.h>
#include <string>
namespace udpscope {
/// The About dialog: version, commit, build timestamp and where the session files live.
/// Modal on `parent`. `session_path` is this run's own session file.
void show_about(GtkWindow* parent, const std::string& session_path);
} // namespace udpscope
```
- [ ] **Step 6: Write the About dialog**
Create `src/help_window.cpp`:
```cpp
// SPDX-License-Identifier: MIT
#include "help_window.hpp"
#include <cstdio>
#include "build_info.hpp"
#include "layout_store.hpp"
namespace udpscope {
namespace {
/// Nulled by the `close-request` handler so a second open cannot present a dead widget.
/// One pointer per window, file-scope because there is one application.
GtkWidget* g_about = nullptr;
gboolean on_window_close(GtkWindow* window, gpointer slot) {
(void)window;
*static_cast<GtkWidget**>(slot) = nullptr;
return FALSE; // let the default handler destroy it
}
} // namespace
void show_about(GtkWindow* parent, const std::string& session_path) {
if (g_about != nullptr) {
gtk_window_present(GTK_WINDOW(g_about));
return;
}
// GtkAboutDialog is not deprecated in GTK 4 (unlike GtkShortcutsWindow), so the
// stock dialog is used here and the overview is hand-rolled.
GtkWidget* dialog = gtk_about_dialog_new();
g_about = dialog;
GtkAboutDialog* about = GTK_ABOUT_DIALOG(dialog);
gtk_about_dialog_set_program_name(about, "udpscope");
gtk_about_dialog_set_version(about, build_info::kVersion);
gtk_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources.");
gtk_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11);
char details[1024];
std::snprintf(details, sizeof(details),
"Commit: %s\nBuilt: %s\nSettings: %s\nSession file: %s",
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
session_path.c_str());
gtk_about_dialog_set_system_information(about, details);
gtk_window_set_transient_for(GTK_WINDOW(dialog), parent);
gtk_window_set_modal(GTK_WINDOW(dialog), TRUE);
g_signal_connect(dialog, "close-request", G_CALLBACK(on_window_close), &g_about);
gtk_window_present(GTK_WINDOW(dialog));
}
} // namespace udpscope
```
- [ ] **Step 7: Add the About menu item**
In `src/ui.cpp`, add the include beside the other project headers at the top of the file:
```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<App*>(user);
if (app->menu_button != nullptr) {
GtkWidget* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_popdown(GTK_POPOVER(popover));
}
show_about(GTK_WINDOW(app->window),
layout_path(app->model->config().server, app->model->config().port));
}
```
Check how `on_export_clicked` reaches the window and the model (`app->window`, `app->model`) and use the same member names; if the menu's popover is dismissed automatically by the existing items, drop the `popdown` block rather than adding a second mechanism.
- [ ] **Step 8: Build**
Run: `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo && cmake --build build`
Expected: configures and builds with no warnings from anything under `src/`. `build/build_info.cpp` exists and its `kCommit` is a real hash.
- [ ] **Step 9: Verify the generated file and the dialog**
Run: `cat build/build_info.cpp`
Expected: `kVersion = "0.1.0"`, `kCommit` a `git describe` string, `kTimestamp` today's date with ` UTC`.
Then confirm the build info reaches the binary and the About dialog opens and closes without a critical. Start the mock, run the app under xvfb with the existing exit probe, and grep the output:
```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::u32>(plot::input::Mod::Alt);
(void)kAltMod;
const f64 x_before = f.plot.x_range(f.ax).span();
const f64 y_before = f.plot.y_range(f.ay).span();
f.controller.on_key({plot::input::kKeyZoomIn, kCtrl, true});
PLOT_CHECK(f.plot.x_range(f.ax).span() < x_before);
PLOT_CHECK_NEAR(f.plot.y_range(f.ay).span(), y_before, 1e-12);
}
PLOT_TEST(alt_plus_zooms_only_y) {
Fixture f;
f.ay = f.plot.add_y_axis({"v", {-1.0, 1.0}, false, plot::AxisPlace::Left, false});
constexpr plot::u32 kAlt = static_cast<plot::u32>(plot::input::Mod::Alt);
const f64 x_before = f.plot.x_range(f.ax).span();
const f64 y_before = f.plot.y_range(f.ay).span();
f.controller.on_key({plot::input::kKeyZoomIn, kAlt, true});
PLOT_CHECK_NEAR(f.plot.x_range(f.ax).span(), x_before, 1e-12);
PLOT_CHECK(f.plot.y_range(f.ay).span() < y_before);
}
```
`kCtrl` is already a file-scope constant in that file; `kAlt` is not, hence the local `constexpr`. Delete the unused `kAltMod`/`(void)` pair from the first test if the file's style prefers it — it is there only to make the two tests read alike, and `-Wunused` would otherwise fire. Match `Fixture`'s existing `add_y_axis` call shape exactly: read how another test in the file adds `f.ay` and copy it rather than trusting the argument list above.
- [ ] **Step 2: Run them and watch them fail**
Run: `cmake --build deps/plotter/build/debug && xvfb-run -a deps/plotter/build/debug/tests/plot_tests 2>&1 | tail -20`
Expected: `alt_plus_zooms_only_y` **fails** (the x span shrank too) and `ctrl_plus_zooms_only_x` **fails** (the y span shrank too). If `deps/plotter/build/debug` does not exist, configure it first:
`cmake -S deps/plotter -B deps/plotter/build/debug -G Ninja -DCMAKE_BUILD_TYPE=Debug -DPLOT_BUILD_TESTS=ON`
- [ ] **Step 3: Implement the restriction**
In `deps/plotter/src/input/controller.cpp`, inside `Controller::on_key`'s `if (zooms)` block, after `notches` and `factor` are computed and before the axis loops, add:
```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<App*>(user);
if (a->rows.rows.empty() || a->rows.rows[0].plot == nullptr) return G_SOURCE_REMOVE;
plot::Plot& p = *a->rows.rows[0].plot;
const plot::AxisId ax = a->rows.rows[0].x_axis;
const plot::AxisId ay = a->rows.rows[0].y_axis;
plot::input::Controller* c =
a->area != nullptr ? a->area->row_controller(static_cast<plot::u32>(0)) : nullptr;
if (c == nullptr) {
std::fprintf(stderr, "[zoomkeys] no controller for row 0\n");
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
const plot::Range x0 = p.x_range(ax);
const plot::Range y0 = p.y_range(ay);
c->on_key({plot::input::kKeyZoomIn,
static_cast<plot::u32>(plot::input::Mod::Ctrl), true});
const plot::Range x1 = p.x_range(ax);
const plot::Range y1 = p.y_range(ay);
const int y_held = std::abs(y1.span() - y0.span()) < 1e-9 ? 1 : 0;
c->on_key({plot::input::kKeyZoomIn,
static_cast<plot::u32>(plot::input::Mod::Alt), true});
const plot::Range x2 = p.x_range(ax);
const plot::Range y2 = p.y_range(ay);
const int x_held = std::abs(x2.span() - x1.span()) < 1e-9 ? 1 : 0;
std::fprintf(stderr,
"[zoomkeys] ctrl+plus x=[%.4f..%.4f]->[%.4f..%.4f] y_held=%d | "
"alt+plus y=[%.4f..%.4f]->[%.4f..%.4f] x_held=%d\n",
x0.min, x0.max, x1.min, x1.max, y_held, y1.min, y1.max, y2.min, y2.max,
x_held);
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
```
`PlotArea::row_controller(plot::u32)` is the accessor the app already uses — `src/ui.cpp:940`
reaches a row's controller through it, and `tick()` polls it per row at `src/ui.cpp:4585-4586`
to find the row holding a dragged cursor. Use it; do **not** add a new accessor to the adapter.
- [ ] **Step 2: Register it**
Beside the other probe registrations, add:
```cpp
if (const char* env = g_getenv("UDPSCOPE_PROBE_ZOOMKEYS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), zoomkeys_probe, &app);
}
```
- [ ] **Step 3: Build**
Run: `cmake --build build`
Expected: no warnings.
- [ ] **Step 4: Run it inside a pause**
```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 `<file>` line and one registry row. Tasks 5, 6 and 7 add rows to the same table.
- [ ] **Step 1: Write the four page bodies**
Pango markup, so `&`, `<` and `>` must be escaped as `&amp;`, `&lt;`, `&gt;`. Every page has the same four parts in the same order: an opening paragraph (what it does), **How to reach it**, **Worth knowing**, **Example**. The numbers in the examples are the ones the probes actually report — do not round them.
Create `src/help/01-overview.txt`:
```
<b>What udpscope is</b>
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each
with up to 4 traces; every row shares one time axis, so the whole figure scrolls together.
Acquisition runs on its own thread and writes a ring buffer sized from --buffer-mb; the
drawing thread reads whatever that ring holds when a frame starts.
<b>How to reach it</b>
Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it
with no <tt>--server</tt> and pick one from the chooser. The signals arrive disabled; turn
one on in the side panel (<tt>s</tt>) and drag it onto a row.
<b>Worth knowing</b>
• Nothing is acquired until a signal is enabled. Enabling is what starts acquisition, so a
freshly opened scope with an idle side panel is working correctly.
• The stream carries its own clock. Every time on screen is a stream timestamp, never the
local wall clock, which is why a stalled source freezes the view instead of scrolling it.
• The window remembers itself. Rows, traces, the time window, calibration, math channels
and measurements are written to a session file per host and port, and reloaded next time.
<b>Example</b>
A 3-signal source at 10 kS/s with a 200 MB buffer holds 1677 s of history. Enable all three,
drop them on row 1, and the status bar reads <tt>● live · 10.0 kS/s · 2 s window</tt>.
```
Create `src/help/02-source.txt`:
```
<b>Choosing a source</b>
A source is a host and a port, optionally reached through a multicast group on a named
interface. Pass it on the command line, or let udpscope ask.
<b>How to reach it</b>
Run with <tt>--server</tt> and the app connects straight away. Run <i>without</i>
<tt>--server</tt> and a chooser appears first: the sessions udpscope has stored, newest
first, plus a <b>New host…</b> form for one it has not seen. The chooser runs before any
buffer is allocated and before any socket is opened, so declining it costs nothing.
<b>Worth knowing</b>
• The list is the sessions that <i>named a signal</i>. A session whose streamer was down
stores empty slot names and is filtered out, which is what keeps the list the sources you
actually configured rather than every port you have ever typed.
• A stored row reconnects the way it was reached. The multicast group and the interface are
written inside the session file, because the file's name (<tt>host_port.toml</tt>) cannot
carry them.
• <tt>--multicast</tt> still needs <tt>--server</tt>: the CONFIG message is fetched from the
server, not from the group.
• The <tt>×</tt> beside a row deletes that session file. Nothing else is removed.
<b>Example</b>
<tt>udpscope</tt> with two stored sessions lists
<tt>127.0.0.1:8000 plots=1 signals=3</tt> and
<tt>10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9</tt>.
Activating the first reports <tt>[sources] picked 127.0.0.1:8000</tt> and continues into
the scope with that session's layout already loaded.
```
Create `src/help/03-signals.txt`:
```
<b>Signals and calibration</b>
The source's CONFIG message describes its signals: a name, a unit and a type. udpscope turns
each enabled signal into a <i>channel</i> — a ring of values beside the shared time ring —
and a channel is what a trace plots. Calibration is a scale and an offset applied at ingest.
<b>How to reach it</b>
The side panel (<tt>s</tt>) lists every signal with a switch. The <b>Signal list</b> item in
the overflow menu opens the editor where the unit and the calibration live.
<b>Worth knowing</b>
• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
nobody acquires can never fire, so arming a trigger enables its signal for you.
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
for a second after one of those is the ring refilling, not a fault.
• The stream's first signal is usually its <i>timestamp</i>, and that one is never acquired.
It cannot be plotted, measured or used as a math source, and udpscope refuses to put it in
a row rather than binding a trace that would read nothing for ever.
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
is one colour and the side panel's chip identifies it unambiguously. A signal on no row
gets a grey chip rather than none, so the panel's rows keep their height.
<b>Example</b>
A source declaring <tt>time</tt>, <tt>sine</tt>, <tt>ramp</tt> and <tt>noise</tt> yields three
plottable signals. Enable all three and the exit dump reads <tt>3 signals, 3 channels</tt>;
<tt>time</tt> is not among them.
```
Create `src/help/04-layout.txt`:
```
<b>Rows, layout and drag &amp; drop</b>
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
time axis and one left gutter, so their y labels line up and their windows cannot drift apart.
<b>How to reach it</b>
Drag a signal's chip out of the side panel (<tt>s</tt>) and drop it on a row to add a trace.
Press <tt>l</tt>, or pick <b>Layout editor</b> from the overflow menu, to add and remove rows:
a <tt>×</tt> appears at the top left of each row and a <tt>+</tt> on each boundary between
rows, including above the first and below the last. The editor's own button in the top-right
corner leaves it.
<b>Worth knowing</b>
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
traces already on a row the drop is refused.
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
puts what row 1 plotted onto row 2 and leaves the new row empty — the rows are not
renumbered under you.
• A trace is remembered by the signal's <i>name</i>, not by its channel number, because a new
CONFIG or an enable renumbers the channels and an index would silently re-point the trace.
• Adding or removing a row rebuilds the figure, which is cheap, but it also re-deals every
trace. A frozen trigger event survives the rebuild: the pinned window is restated rather
than re-framed on the live edge.
<b>Example</b>
With three traces on row 1, pressing <tt>l</tt> shows 3 buttons (one <tt>×</tt>, two
<tt>+</tt>). Clicking the <tt>+</tt> above row 1 gives 2 rows and 5 buttons, with the three
traces now on row 2 — the layout probe reports exactly this as
<tt>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</tt>.
```
- [ ] **Step 2: Write the GResource manifest**
Create `src/help/udpscope-help.gresource.xml`:
```xml
<?xml version="1.0" encoding="UTF-8"?>
<!--
SPDX-License-Identifier: MIT
The help pages, compiled into the binary for the same reason the icons are: udpscope is
run straight out of the build tree and has no install step, so a search path would have
to be located at runtime and the failure mode of not locating it is a help window full
of missing pages.
-->
<gresources>
<gresource prefix="/org/udpscope/help">
<file>01-overview.txt</file>
<file>02-source.txt</file>
<file>03-signals.txt</file>
<file>04-layout.txt</file>
</gresource>
</gresources>
```
Tasks 5, 6 and 7 add their `<file>` lines here.
- [ ] **Step 3: Compile the bundle in CMake**
In `CMakeLists.txt`, after the icon GResource `add_custom_command` (line 52) and before the build-info block Task 1 added, insert:
```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 <cstddef>` beside the existing includes.
- [ ] **Step 5: Implement the registry, the loader and the window**
In `src/help_window.cpp`, add to the anonymous namespace (above `show_about`):
```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 <file> line in the manifest and one row here.
/// The order here is the order the sidebar shows; `section` groups consecutive rows.
constexpr HelpPage kPages[] = {
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt"},
{"Getting started", "Choosing a source", "/org/udpscope/help/02-source.txt"},
{"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.txt"},
{"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"},
};
constexpr int kPageCount = static_cast<int>(sizeof(kPages) / sizeof(kPages[0]));
/// The page's markup, or a one-line stand-in. `ok` reports whether the real body loaded
/// *and* parsed: a missing resource and markup Pango rejects both render the stand-in,
/// because a help window that fails to open is worse than one page that says so.
std::string load_page(const HelpPage& page, bool* ok) {
*ok = false;
GError* error = nullptr;
GBytes* bytes = g_resources_lookup_data(page.resource, G_RESOURCE_LOOKUP_FLAGS_NONE,
&error);
if (bytes == nullptr) {
if (error != nullptr) g_error_free(error);
return "<i>This page is missing from the build.</i>";
}
gsize size = 0;
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &size));
std::string body(data, size);
g_bytes_unref(bytes);
if (!pango_parse_markup(body.c_str(), -1, 0, nullptr, nullptr, nullptr, &error)) {
if (error != nullptr) g_error_free(error);
return "<i>This page could not be displayed: its markup is malformed.</i>";
}
*ok = true;
return body;
}
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label) {
if (row == nullptr) return;
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page"));
if (idx < 0 || idx >= kPageCount) return;
bool ok = false;
const std::string body = load_page(kPages[idx], &ok);
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
}
```
Then, after `show_about()`, add:
```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), "<b><small>%s</small></b>", current_section);
gtk_label_set_markup(GTK_LABEL(heading), markup);
gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
gtk_widget_set_margin_start(heading, 10);
gtk_widget_set_margin_top(heading, 10);
gtk_widget_set_margin_bottom(heading, 2);
GtkWidget* heading_row = gtk_list_box_row_new();
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(heading_row), FALSE);
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(heading_row), FALSE);
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(heading_row), heading);
gtk_list_box_append(GTK_LIST_BOX(list), heading_row);
}
GtkWidget* label = gtk_label_new(kPages[i].title);
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
gtk_widget_set_margin_start(label, 18);
gtk_widget_set_margin_end(label, 10);
gtk_widget_set_margin_top(label, 4);
gtk_widget_set_margin_bottom(label, 4);
GtkWidget* row = gtk_list_box_row_new();
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
g_object_set_data(G_OBJECT(row), "help-page", GINT_TO_POINTER(i));
gtk_list_box_append(GTK_LIST_BOX(list), row);
}
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), body);
GtkWidget* list_scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(list_scroll), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(list_scroll), list);
gtk_widget_set_size_request(list_scroll, 220, -1);
GtkWidget* split = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
gtk_box_append(GTK_BOX(split), list_scroll);
gtk_box_append(GTK_BOX(split), gtk_separator_new(GTK_ORIENTATION_VERTICAL));
gtk_box_append(GTK_BOX(split), body_scroll);
gtk_window_set_child(GTK_WINDOW(window), split);
// Select the first page so the window never opens blank.
GtkListBoxRow* first = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 1);
if (first != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(list), first);
g_signal_connect(window, "close-request", G_CALLBACK(on_window_close), &g_help);
gtk_window_present(GTK_WINDOW(window));
}
```
Add `#include <string>` and `#include <cstdio>` if they are not already present. The sidebar's first row is a non-selectable section heading, hence `get_row_at_index(list, 1)` for the first real page.
- [ ] **Step 6: Add the Help menu item**
In `src/ui.cpp`'s `build_toolbar()`, before the About item added in Task 1, append:
```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<App*>(user);
if (app->menu_button != nullptr) {
GtkWidget* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_popdown(GTK_POPOVER(popover));
}
show_help(GTK_WINDOW(app->window));
}
```
- [ ] **Step 7: Add the help probe**
In `src/ui.cpp`'s anonymous namespace:
```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<App*>(user);
const HelpReport report = help_self_check();
const std::string session =
layout_path(a->model->config().server, a->model->config().port);
show_help(GTK_WINDOW(a->window));
show_about(GTK_WINDOW(a->window), session);
close_help_windows();
std::fprintf(stderr,
"[help] pages=%d parsed=%d shortest='%s' %zu B; "
"about version=%s commit=%s built=%s settings=%s; closed ok\n",
report.pages, report.parsed, report.shortest_title.c_str(),
report.shortest_bytes, build_info::kVersion, build_info::kCommit,
build_info::kTimestamp, config_dir().c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
```
Task 8 extends this same line with a ` shortcuts: app=12 plot=13;` field and adds
`show_shortcuts()` to the window sequence; this task prints the line without that field rather
than printing a stub for it. Add the include `"build_info.hpp"`; `"help_window.hpp"` is already
included from Task 1.
`close_help_windows()` is a new export — add to `src/help_window.hpp`:
```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<guint>(std::atoi(env)), help_probe, &app);
}
```
- [ ] **Step 8: Build**
Run: `cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo && cmake --build build`
Expected: `Compiling udpscope help resources` runs, and the build is warning-free.
- [ ] **Step 9: Run the probe**
```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 `<file>` lines
- Modify: `src/help_window.cpp` — four rows in `kPages`
**Interfaces:**
- Consumes: Task 4's `kPages` registry, the `/org/udpscope/help` prefix, and `help_self_check()`'s `parsed` count.
- Produces: nothing new. The help probe's `pages=` rises from 4 to 8.
- [ ] **Step 1: Write the four page bodies**
Create `src/help/05-window.txt`. This is the spec's approved template page, in the markup style of the pages Task 4 wrote:
```
<b>Time window</b>
The window is how much time the plot shows. Every row shares one window: the figure has a
single x link group, so the rows cannot drift apart. The trigger's event span follows it, and
the pre-trigger share is a share of <i>this</i> window.
<b>How to reach it</b>
The header bar's window button opens a popover: type a number, pick a unit under it, or step a
decade with the arrows. The button's own label is a reading of the current window in a natural
unit, width-locked so a change of window cannot widen the header bar.
<b>Worth knowing</b>
• A window too long to store at the wire rate is <i>decimated, not refused</i>. The shared clock
takes a power-of-two stride, and each group of samples is <b>averaged</b> rather than strided,
so a tone above the stored Nyquist is attenuated instead of aliasing to a false low frequency.
• The stamp stored for a group is the group's <i>mean</i>, which is where the averaging puts the
value. Stamping at the end of a group would shift every trace half a stored sample late.
• Moving the window across a decimation boundary rebuilds the buffer and drops the history.
Walking it inside one decade is free.
• The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge
found on a sample that is not in the buffer could not be cut out of it.
<b>Example</b>
1 MS/s with 200 MB of buffer. A 2 s window stores every sample (<tt>time x1</tt>). Widen it to
600 s and the stride becomes <tt>x16</tt>: 625 S/s stored, anti-aliased at 312.5 Hz. A 2 kHz
sine seen through that window reads <tt>y=[-0.059, 0.079]</tt> — attenuated to about 6%, not
folded down to a full-amplitude 125 Hz one.
```
Create `src/help/06-navigate.txt`:
```
<b>Navigating the plot</b>
The plot pans and zooms with the mouse and with the keyboard. Every gesture acts on the row the
pointer is over, and because the rows share one time axis, an x change moves all of them.
<b>How to reach it</b>
<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
<b>Drag</b> pans · <b>Ctrl+drag</b> is a box zoom on x · <b>Alt+drag</b> a box zoom on y.
<b>+</b> and <b>-</b> zoom both axes; <b>Ctrl</b> with them restricts to x, <b>Alt</b> to y.
<b>Arrow keys</b> pan, <b>f</b> fits, <b>Escape</b> cancels a gesture in progress.
The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
<b>Worth knowing</b>
• The keyboard zoom's modifiers are <i>not</i> the wheel's, and that is deliberate: on common
layouts <tt>+</tt> <i>is</i> shift+<tt>=</tt>, so a bare zoom-in already arrives with Shift
set and Shift could not mean anything. Ctrl/Alt mirror the box-zoom drags instead.
• A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is
held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not
outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data.
• A live view is clamped to the window you asked for, not to what the ring happens to hold.
That is why a buffer that has only just started filling leaves its left-hand side empty
instead of squeezing the window down to the first few samples.
• Zooming hard into a held capture is exact. The renderer finds the visible samples by binary
search over the real timestamps, never over a nominal sample grid, so a capture whose clock
wandered does not drift away from its axis as you zoom.
<b>Example</b>
Pause a live view, then press <tt>Ctrl++</tt>: x narrows and y does not move. Press
<tt>Alt++</tt>: y narrows and x does not. Eight doublings into a held 2 s capture, the trace
still reaches both margins — the zoom probe reports <tt>gap_px</tt> and <tt>end_px</tt> within
a pixel or two at every step.
```
Create `src/help/07-pause.txt`:
```
<b>Pause and play</b>
Pause freezes the <i>display</i>, not the acquisition. The producer keeps filling the ring at
full rate; the view binds an immutable snapshot of the window that was on screen, and the axis
is pinned to it.
<b>How to reach it</b>
The pause button in the toolbar, or <tt>Space</tt>. Press it again to resume, which rebinds the
live ring and puts the window back on the newest data.
<b>Worth knowing</b>
• Nothing is lost while paused. Because the producer never stops, resuming shows current data
with no hole in the history behind it.
• The axis is pinned to the <i>snapshot's own stamps</i>, not to the window that was requested.
A window reaching back past the oldest retained sample yields a shorter snapshot, and the
pinned axis is that shorter span — so you see a narrower view rather than an empty margin.
• A paused view is pinned exactly as a held trigger event is: zoom in freely, but a pan or a
zoom out is clamped to the snapshot's data.
• Pausing is the easiest way to measure a layout or read a cursor precisely, because nothing
under the pointer moves between frames.
<b>Example</b>
Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads
<tt>[-1.40 .. 0.00]</tt> rather than <tt>[-2.00 .. 0.00]</tt> with 0.6 s of blank. The pause
probe reports <tt>ring_produced</tt> still growing across the pause and
<tt>event_size</tt> non-zero exactly while paused.
```
Create `src/help/08-trigger.txt`:
```
<b>Trigger</b>
The trigger watches one signal for an edge through a level and captures a window around it.
The captured window is frozen on screen until you re-arm.
<b>How to reach it</b>
The trigger button in the toolbar (<tt>t</tt>) reveals the trigger panel: the signal, the level,
the edge (rising, falling, either), the mode (Normal or Single) and the pre-trigger share.
<b>Arm</b> starts watching; <tt>a</tt> arms and disarms from the keyboard.
<b>Worth knowing</b>
• Arming <i>enables</i> the signal if it was off, because a trigger on a signal nobody acquires
can never fire.
• The pre-trigger share is a share of the <i>display window</i>, not of the ring. A 2 s window
at 50% captures 1 s before the edge and 1 s after.
• There are four display states, and the status bar names each: <tt>armed</tt> (watching,
nothing drawn yet), <tt>capturing</tt> (the edge was accepted and the post window is filling
<i>in view</i>, with the marker at x = 0), <tt>event held</tt> (the frozen capture) and
<tt>disarmed</tt>. A held capture is never quietly replaced by live data.
• The first edge after arming is only accepted once the history reaches back a full pre-trigger
window, so a capture may legitimately be shorter than you asked for. The axis is pinned to
what the capture actually got.
• Disarming <i>during</i> a capture publishes what the capture got to, so the picture on screen
at that moment stays on screen. No event fired, so the event count does not move.
• The trigger reads the <b>stored</b> signal — decimated and averaged — not the wire signal.
A spike narrower than one decimation group is filtered away rather than triggering on data
the buffer does not contain.
<b>Example</b>
A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans
<tt>[-1.000 .. 1.000]</tt> with <tt>n=20001</tt> samples at 10 kS/s — the span times the real
rate, plus one — and the marker sits at x = 0 throughout.
```
- [ ] **Step 2: Add them to the manifest**
In `src/help/udpscope-help.gresource.xml`, after `<file>04-layout.txt</file>`:
```xml
<file>05-window.txt</file>
<file>06-navigate.txt</file>
<file>07-pause.txt</file>
<file>08-trigger.txt</file>
```
- [ ] **Step 3: Add them to the registry**
In `src/help_window.cpp`'s `kPages`, after the `04-layout` row:
```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 `<file>` lines
- Modify: `src/help_window.cpp` — three rows in `kPages`
**Interfaces:**
- Consumes: Task 4's `kPages` registry.
- Produces: nothing new. `pages=` rises from 8 to 11.
- [ ] **Step 1: Write the three page bodies**
Create `src/help/09-cursors.txt`:
```
<b>Cursors</b>
Cursors read values off the traces. There are two kinds: one <i>shared vertical pair</i> that
every row carries at the same time, and <i>horizontal rulers</i>, one pair per row, that belong
to that row alone.
<b>How to reach it</b>
The cursors button in the toolbar, or <tt>c</tt>. The pane below the plot shows the readings and
owns a <b>Reset</b> button. Drag a marker to move it. The mode dropdown offers four modes:
<tt>vertical</tt> (the shared time pair), <tt>horizontal</tt> (the per-row rulers),
<tt>both</tt>, and <tt>mix</tt>.
<b>Worth knowing</b>
• The vertical pair is <i>one</i> time for the whole figure, so moving it on any row moves it on
all of them. That is what makes <tt>dt</tt> meaningful across rows.
• A ruler belongs to its row and nothing shares it, so two rows' rulers are independent.
• <tt>mix</tt> is the reading mode: one line per <i>bound</i> trace, with the trace's own values
at each marker and their difference. A slot with a name but no channel behind it is not a
trace and gets no line.
• A marker past the newest sample reads <tt>--</tt>. With only one marker over data the row
reads <tt>partial</tt>: a difference between a number and nothing is not a number.
• In <tt>mix</tt> each marker also places a crossing annotation per trace, which is what lets
you see on the plot which sample a number came from.
• The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
time row, by design.
<b>Example</b>
Three traces in <tt>mix</tt> mode with both markers over data: 3 lines and 6 crossing marks,
each line reading like <tt>sine 0.31 V -0.84 V -1.15 V</tt>. Move the second marker past the
newest sample and the third column reads <tt>--</tt>.
```
Create `src/help/10-measurements.txt`:
```
<b>Measurements</b>
A measurement is a statistic of the <i>visible window</i> of one signal, recomputed as that
window moves. The pane lists them with their values; each can also draw its result on the plot.
<b>How to reach it</b>
The measurements button in the toolbar, or <tt>m</tt>. The pane's <tt>+</tt> adds one; clicking
a row opens the dialog where the signal, the type, the threshold share and the on-plot marks are
set, with <b>Save</b> and <b>Delete</b>.
<b>Worth knowing</b>
• The types are average, top, bottom, min, max, peak to peak, amplitude, pulse duration, period
and frequency. <i>top</i> and <i>bottom</i> are the means of the upper and lower halves, which
is why a unit sine reads 0.637 (2/π) rather than 1.
• The on-plot marks come in two kinds and the mix is the reading: a <i>level</i> mark is a ruler
at a value, a <i>time</i> mark is a marker at a position. An average draws one level mark; a
max draws a level mark <i>and</i> a time mark at the first sample that reached it.
• Marks cannot be dragged. A measurement mark says what the measurement measured, so it is
created unselectable — as is the trigger marker.
• A pass is throttled to a cadence rather than run every frame, so a ruler over a live view is
<i>stepped</i>. The number and the ruler come from the same pass, so they always agree.
• An invalid pass keeps its <i>level</i> marks and drops its <i>time</i> marks. A level is a
property of the signal and outlives the window; a position inside the window does not. That is
what stops the rulers blinking off at a mode change.
• A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible
invented number. Frequency over a window holding one edge is the usual case.
• <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds;
every other type carries the channel's own unit.
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
window. The values are still exact: a bucket's sum is a sum.
<b>Example</b>
On a 1 Hz ±1 sine: <tt>average=0</tt>, <tt>top=0.636620</tt>, <tt>bottom=-0.636620</tt>,
<tt>max=1</tt>, <tt>min=-1</tt>, <tt>peak to peak=2</tt>, <tt>amplitude=1</tt>,
<tt>pulse duration=0.5 s</tt> (duty 0.5), <tt>period=1 s</tt>, <tt>frequency=1 Hz</tt>. Over a
2 M-sample window those two passes read about 0.1% of the samples.
```
Create `src/help/11-math.txt`:
```
<b>Math channels</b>
A math channel is a signal computed from other signals. You build it as a small graph — source
nodes, operations, one output — and udpscope then treats the result as an ordinary channel.
<b>How to reach it</b>
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel; opening one
shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
the canvas, click an output port then an input port to wire them, drag a box to move it. The
orange dot marks the output node.
<b>Worth knowing</b>
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
cycle is impossible by construction rather than rejected by a check.
• A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted,
measured, exported, and used as a source for another math channel.
• The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
Hz means the rate the filter will actually run at. Change the window across a decimation
boundary and the coefficients are rebuilt for the new rate.
• A source may only name an <i>acquired</i> channel. The stream's timestamp signal is not one:
a graph sourced from it validates and then reads NaN for ever, so the dropdown does not
offer it.
• The output defaults to the <i>last</i> node, and a node added to a linear graph becomes the
output. Choosing an earlier node explicitly is respected.
• Every numeric field starts at its own identity value — gain 1, offset 0 — not at the bottom
of its range.
<b>Example</b>
<tt>[sine] → [gain x2]</tt> gives a channel reading <tt>y=[-2, 2]</tt>. A 100 Hz low pass on the
same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at
0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel must hold exactly as many samples as
the shared clock has time slots.
```
- [ ] **Step 2: Add them to the manifest**
In `src/help/udpscope-help.gresource.xml`, after `<file>08-trigger.txt</file>`:
```xml
<file>09-cursors.txt</file>
<file>10-measurements.txt</file>
<file>11-math.txt</file>
```
- [ ] **Step 3: Add them to the registry**
In `src/help_window.cpp`'s `kPages`, after the `08-trigger` row:
```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 `<file>` lines
- Modify: `src/help_window.cpp` — three rows in `kPages`
**Interfaces:**
- Consumes: Task 4's `kPages` registry.
- Produces: nothing new. `pages=` reaches its final 14, which Task 11's verification asserts.
- [ ] **Step 1: Write the three page bodies**
Page 14 describes the status bar this plan is about to change, so it is written to the shape Task 10 builds, not to the current bar.
Create `src/help/12-export.txt`:
```
<b>Saving and export</b>
Export writes the <i>visible range</i> of every plotted trace to a file: NumPy <tt>.npy</tt> or
<tt>.csv</tt>, chosen by the extension you type.
<b>How to reach it</b>
<b>Export visible range…</b> in the overflow menu, or <tt>Ctrl+E</tt>. A file chooser asks where;
the format follows the path's extension.
<b>Worth knowing</b>
• The table is collected at the moment you click, not when the chooser returns. A live view keeps
scrolling while a dialog is up, so a late collection would write a window you never saw.
• The rows are the <b>stored samples</b>, never the LOD buckets the plot draws from. A row count
far below the span times the stored rate means something cut the table from the pyramid.
• The stored rate is not always the wire rate. A window long enough to need decimation is stored
averaged at a lower rate, and the export carries <i>that</i> — read the status bar's ⓘ popover
for the figure before trusting a sample count.
• Two time columns. <tt>time</tt> is the display timeline (relative to the trigger edge while an
event is held) and <tt>stream_time</tt> is the source's own clock. Both are strictly increasing
and evenly spaced; duplicate or backwards stamps would mean the file held two clocks.
• An export taken <i>mid-capture</i> writes the partly filled window that is on screen, which is
shorter than the event span. Export outside a capture if you want the whole event.
<b>Example</b>
A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
(<tt>time</tt>, <tt>stream_time</tt>, three signals). The <tt>.npy</tt> is
<tt>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</tt> bytes. Read it back with
<tt>pd.DataFrame(np.load(path))</tt>.
```
Create `src/help/13-sessions.txt`:
```
<b>Sessions and settings</b>
udpscope stores one session file per source, so reopening a source gives you the scope you
closed. There is no global preferences file: the session <i>is</i> the settings.
<b>How to reach it</b>
Nothing to open — the file is written as you work. Its path is in the About dialog, and the
directory is <tt>$XDG_CONFIG_HOME/udpscope</tt>, or <tt>~/.config/udpscope</tt> when that is
unset. Each file is named <tt>&lt;host&gt;_&lt;port&gt;.toml</tt>.
<b>Worth knowing</b>
• What a session persists: the time window, the rows and each row's slot signals, the
calibration, which signals are enabled, the math channels with their graphs, and the
measurements pane.
• Signal identity is the <i>name</i> throughout, because a new CONFIG renumbers the channels.
• A file written by a run against a source whose streamer was down would name no signals at all,
so such a run does <b>not</b> write: a configured session is never overwritten by a run that
configured nothing.
• The multicast group and the interface live <i>inside</i> the file, because its name cannot
carry them. A file from an older build has neither key and loads as unicast on the default
route.
• The startup chooser lists only files that name at least one signal, which is what keeps it the
sources you configured rather than every port you have ever tested against.
• Deleting a file is a supported way to start a source over; the chooser's <tt>×</tt> does
exactly that.
<b>Example</b>
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1 and set a 2 s window.
<tt>~/.config/udpscope/127.0.0.1_8000.toml</tt> now holds <tt>rows=1 window_s=2</tt> with
<tt>signals = ["sine", "ramp", "noise", ""]</tt>, and the next run reports
<tt>layout loaded from …/127.0.0.1_8000.toml</tt> with those signals already acquired.
```
Create `src/help/14-status.txt`:
```
<b>Status bar and diagnostics</b>
The status bar is two tiers. The bar itself is one glance line — the state, the rate and the
window — and everything else is behind the ⓘ button at its right-hand end.
<b>How to reach it</b>
The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popover.
<b>Worth knowing</b>
• The badge is one glyph in three colours: lit and dim alternating means data is arriving, and a
steady idle colour means it is not. A badge that never blinks is not a working badge.
• The state field is one word from a fixed set: <tt>live</tt>, <tt>paused</tt>, <tt>armed</tt>,
<tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. In trigger mode the bar adds
exactly one more field, how long ago the last event was.
• Nothing on the bar may change width with the stream, because the bar sits directly above the
plot and a wider label would relayout it. That is why the rate collapses to one figure or a
range rather than listing per channel, and why an elapsed time steps to a coarser unit instead
of growing digits.
• <tt>gaps</tt> counts packets the receiver knows it missed. It is the number that matters: a
non-zero count means samples are absent from the buffer, not merely late.
• <tt>reconnects</tt> counts sessions re-established with the source. Over unicast a climbing
count usually means a second client is taking the source's single session.
• The starvation notice — <i>connected, but no new frames in the last second</i> — is that same
situation seen from the other side. Pointing the app at a port where nothing answers produces
it too.
• <tt>stored</tt> versus <tt>wire</tt> in the popover is the decimation. They differ exactly
when the window is too long to store at full rate.
<b>Example</b>
The bar reads <tt>● live · 10.0 kS/s · 2 s window</tt>. Behind ⓘ:
<tt>Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3 channels</tt>,
<tt>Buffer 200.0 MB · 1677 s span · time x1 / stored 10.0 kS/s (wire 10.0 kS/s)</tt>,
<tt>Plot 1 3 traces · 195000 samples</tt>, <tt>Reconnects 0</tt>.
```
- [ ] **Step 2: Add them to the manifest**
In `src/help/udpscope-help.gresource.xml`, after `<file>11-math.txt</file>`:
```xml
<file>12-export.txt</file>
<file>13-sessions.txt</file>
<file>14-status.txt</file>
```
- [ ] **Step 3: Add them to the registry**
In `src/help_window.cpp`'s `kPages`, after the `11-math` row:
```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 <gdk/gdk.h>
namespace udpscope {
namespace {
/// Bare letters are toggles, Ctrl is for files and windows. Nothing here shadows a key the
/// plot's own controller handles ('+', '-', 'f', the arrows, Escape).
constexpr Accel kAccels[] = {
{GDK_KEY_space, 0, AppAction::PauseToggle, "Space", "Pause or resume the display",
"Acquisition"},
{GDK_KEY_a, 0, AppAction::TriggerArm, "A", "Arm or disarm the trigger", "Acquisition"},
{GDK_KEY_t, 0, AppAction::TriggerPanel, "T", "Show or hide the trigger panel",
"Acquisition"},
{GDK_KEY_c, 0, AppAction::CursorsPane, "C", "Show or hide the cursors pane",
"Panes and editors"},
{GDK_KEY_m, 0, AppAction::MeasuresPane, "M", "Show or hide the measurements pane",
"Panes and editors"},
{GDK_KEY_g, 0, AppAction::MathPane, "G", "Show or hide the math pane",
"Panes and editors"},
{GDK_KEY_l, 0, AppAction::LayoutEditor, "L", "Show or hide the layout editor",
"Panes and editors"},
{GDK_KEY_s, 0, AppAction::SidePanel, "S", "Show or hide the side panel",
"Panes and editors"},
{GDK_KEY_e, GDK_CONTROL_MASK, AppAction::Export, "Ctrl+E",
"Export the visible range", "Files and windows"},
{GDK_KEY_F1, 0, AppAction::Help, "F1", "Open the help window", "Files and windows"},
{GDK_KEY_slash, GDK_CONTROL_MASK, AppAction::Shortcuts, "Ctrl+/",
"Open this shortcut overview", "Files and windows"},
{GDK_KEY_q, GDK_CONTROL_MASK, AppAction::Quit, "Ctrl+Q", "Quit udpscope",
"Files and windows"},
};
constexpr int kAccelCount = static_cast<int>(sizeof(kAccels) / sizeof(kAccels[0]));
/// The plot's own interactions, documentation only. The keyboard's modifiers are not the
/// wheel's: '+' is shift+'=' on common layouts, so Shift could not mean anything there.
constexpr Gesture kGestures[] = {
{"+ / -", "Zoom in or out, both axes", "Plot keyboard"},
{"Ctrl + / Ctrl -", "Zoom the time axis only", "Plot keyboard"},
{"Alt + / Alt -", "Zoom the value axis only", "Plot keyboard"},
{"Left / Right", "Pan along time", "Plot keyboard"},
{"Up / Down", "Pan along values", "Plot keyboard"},
{"F", "Fit the view to the data", "Plot keyboard"},
{"Escape", "Cancel the gesture in progress", "Plot keyboard"},
{"Wheel", "Zoom about the pointer, both axes", "Plot mouse"},
{"Shift+wheel", "Zoom the time axis only", "Plot mouse"},
{"Ctrl+wheel", "Zoom the value axis only", "Plot mouse"},
{"Drag", "Pan", "Plot mouse"},
{"Ctrl+drag", "Box zoom on time", "Plot mouse"},
{"Alt+drag", "Box zoom on values", "Plot mouse"},
};
constexpr int kGestureCount = static_cast<int>(sizeof(kGestures) / sizeof(kGestures[0]));
/// The only modifiers a table row may require. Lock, Num-Lock and the pointer buttons all
/// arrive in the same mask and must be masked out or no row ever matches.
constexpr unsigned kModMask = GDK_CONTROL_MASK | GDK_SHIFT_MASK | GDK_ALT_MASK;
} // namespace
const Accel* app_accels(int* count) {
if (count != nullptr) *count = kAccelCount;
return kAccels;
}
const Gesture* plot_gestures(int* count) {
if (count != nullptr) *count = kGestureCount;
return kGestures;
}
const Accel* find_accel(unsigned keyval, unsigned mods) {
const unsigned wanted = mods & kModMask;
for (const Accel& accel : kAccels) {
if (accel.keyval == keyval && accel.mods == wanted) return &accel;
}
return nullptr;
}
} // namespace udpscope
```
`kGestures` holds **13** rows: the left/right arrows and the up/down arrows are separate rows
because they pan different axes. The probe's `plot=` field therefore reads 13. If a later edit
adds or removes a row, the probe's expected output changes with it — the assertion is that
`plot=` equals `std::size(kGestures)`, which is what `plot_gestures()` reports.
- [ ] **Step 3: Add the source to CMake**
In `CMakeLists.txt`'s `add_executable`, add `src/shortcuts.cpp` before `src/help_window.cpp`.
- [ ] **Step 4: Write the overview window**
In `src/help_window.hpp`, add:
```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), "<tt>%s</tt>", accel_text);
gtk_label_set_markup(GTK_LABEL(keys), markup);
gtk_label_set_xalign(GTK_LABEL(keys), 1.0f);
gtk_widget_set_margin_end(keys, 14);
gtk_grid_attach(GTK_GRID(grid), keys, 0, row, 1, 1);
GtkWidget* what = gtk_label_new(description);
gtk_label_set_xalign(GTK_LABEL(what), 0.0f);
gtk_grid_attach(GTK_GRID(grid), what, 1, row, 1, 1);
}
/// A section heading spanning both columns.
void append_shortcut_heading(GtkWidget* grid, int row, const char* section) {
GtkWidget* heading = gtk_label_new(nullptr);
char markup[128];
std::snprintf(markup, sizeof(markup), "<b>%s</b>", section);
gtk_label_set_markup(GTK_LABEL(heading), markup);
gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
gtk_widget_set_margin_top(heading, row == 0 ? 0 : 14);
gtk_widget_set_margin_bottom(heading, 4);
gtk_grid_attach(GTK_GRID(grid), heading, 0, row, 2, 1);
}
} // namespace
void show_shortcuts(GtkWindow* parent) {
if (g_shortcuts != nullptr) {
gtk_window_present(GTK_WINDOW(g_shortcuts));
return;
}
GtkWidget* window = gtk_window_new();
g_shortcuts = window;
gtk_window_set_title(GTK_WINDOW(window), "Keyboard and mouse shortcuts");
gtk_window_set_default_size(GTK_WINDOW(window), 560, 680);
gtk_window_set_transient_for(GTK_WINDOW(window), parent);
GtkWidget* grid = gtk_grid_new();
gtk_grid_set_row_spacing(GTK_GRID(grid), 2);
gtk_widget_set_margin_start(grid, 20);
gtk_widget_set_margin_end(grid, 20);
gtk_widget_set_margin_top(grid, 16);
gtk_widget_set_margin_bottom(grid, 16);
int row = 0;
const char* section = nullptr;
int accel_count = 0;
const Accel* accels = app_accels(&accel_count);
for (int i = 0; i < accel_count; ++i) {
if (section == nullptr || std::string(section) != accels[i].section) {
section = accels[i].section;
append_shortcut_heading(grid, row++, section);
}
append_shortcut_row(grid, row++, accels[i].accel_text, accels[i].description);
}
int gesture_count = 0;
const Gesture* gestures = plot_gestures(&gesture_count);
for (int i = 0; i < gesture_count; ++i) {
if (section == nullptr || std::string(section) != gestures[i].section) {
section = gestures[i].section;
append_shortcut_heading(grid, row++, section);
}
append_shortcut_row(grid, row++, gestures[i].accel_text, gestures[i].description);
}
GtkWidget* scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll), GTK_POLICY_NEVER,
GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), grid);
gtk_window_set_child(GTK_WINDOW(window), scroll);
g_signal_connect(window, "close-request", G_CALLBACK(on_window_close), &g_shortcuts);
gtk_window_present(GTK_WINDOW(window));
}
```
The tables' rows are grouped by consecutive equal `section`, which is why both tables keep
their sections contiguous. Add this window to `close_help_windows()`:
```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<App*>(user);
if (app->menu_button != nullptr) {
GtkWidget* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_popdown(GTK_POPOVER(popover));
}
show_shortcuts(GTK_WINDOW(app->window));
}
```
- [ ] **Step 6: Extend the help probe**
In `help_probe()`, add `#include "shortcuts.hpp"` at the top of `ui.cpp`, open the third
window in the sequence and report the two counts:
```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<App*>(user);
if (app->window == nullptr) return FALSE;
GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window));
if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE;
const Accel* accel = find_accel(keyval, static_cast<unsigned>(state));
if (accel == nullptr) return FALSE;
run_app_action(*app, accel->action);
return TRUE;
}
```
`GTK_IS_EDITABLE` catches `GtkEntry`, `GtkSpinButton`'s entry and `GtkSearchEntry` alike, which
is every text widget this app has.
- [ ] **Step 3: Write the keys probe**
In the anonymous namespace:
```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<App*>(user);
std::string line = "[keys]";
const auto before_after = [&](const char* name, GtkWidget* button, AppAction action) {
const int before = button != nullptr
? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0)
: -1;
run_app_action(*a, action);
const int after = button != nullptr
? (gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(button)) ? 1 : 0)
: -1;
char buf[96];
std::snprintf(buf, sizeof(buf), " %s: %d->%d |", name, before, after);
line += buf;
};
const bool armed_before = a->model->trigger_armed();
run_app_action(*a, AppAction::TriggerArm);
char armed[64];
std::snprintf(armed, sizeof(armed), " a: armed %d->%d |", armed_before ? 1 : 0,
a->model->trigger_armed() ? 1 : 0);
line += armed;
const bool paused_before = a->acq->paused();
run_app_action(*a, AppAction::PauseToggle);
char paused[64];
std::snprintf(paused, sizeof(paused), " space: paused %d->%d |", paused_before ? 1 : 0,
a->acq->paused() ? 1 : 0);
line += paused;
run_app_action(*a, AppAction::PauseToggle); // leave the run live for the other probes
before_after("t: trigger panel", a->trigger_toggle, AppAction::TriggerPanel);
before_after("c: cursors", a->cursors_toggle, AppAction::CursorsPane);
before_after("m: measures", a->measures_toggle, AppAction::MeasuresPane);
before_after("g: math", a->math_toggle, AppAction::MathPane);
before_after("l: layout", a->layout_toggle, AppAction::LayoutEditor);
before_after("s: side panel", a->sidebar_toggle, AppAction::SidePanel);
// An accel must be refused while text is being typed. Focus the window entry, send 'm'
// through the real handler, and require the measurements toggle not to move.
int refused = -1;
if (a->window_entry != nullptr) {
gtk_widget_grab_focus(a->window_entry);
const int before = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle))
? 1 : 0;
const gboolean handled = on_app_key_pressed(nullptr, GDK_KEY_m, 0,
static_cast<GdkModifierType>(0), a);
const int after = gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->measures_toggle))
? 1 : 0;
refused = (handled == FALSE && before == after) ? 1 : 0;
}
char tail[64];
std::snprintf(tail, sizeof(tail), " editable focused: 'm' refused=%d\n", refused);
line += tail;
std::fprintf(stderr, "%s", line.c_str());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
```
`App::window_entry` (`src/ui.cpp:109`, a `GtkEntry` the window probe already drives at `:6283`)
is the editable the refusal check focuses. It lives inside the window button's popover
(`gtk_menu_button_set_popover` at `:5436`), and a widget in an unmapped popover cannot take
focus — so the check must pop the popover up first and down again:
```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<guint>(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 <cstddef>
#include <cstdint>
#include <string>
namespace udpscope {
/// Everything either tier of the status bar shows, gathered once per frame by the tick so the
/// two cannot disagree. No GTK here on purpose: this is the one part of the status bar that
/// can be reasoned about as text.
struct StatusSnapshot {
/// One word from the fixed set: "live", "paused", "armed", "capturing", "event held",
/// "disarmed". It is a word from a *fixed* set because the bar sits directly above the plot
/// and a field whose width tracked the stream would relayout it every frame.
const char* state_word = "live";
/// The long prose the old single-line bar carried, kept for the detail string and the dump.
std::string state_detail;
bool connected = false;
bool have_config = false;
bool trigger_on = false;
/// Already collapsed to one figure or a range by the caller, without the "rate " prefix.
std::string rate_text;
double stored_rate_hz = 0.0;
double declared_rate_hz = 0.0;
/// Pre-formatted by ui.cpp's format_window(), which the window control and the Fit notices
/// also use, so it stays there rather than moving here.
std::string window_text;
std::uint64_t packets = 0;
std::uint64_t frames = 0;
std::uint64_t gaps = 0;
std::size_t signals = 0;
std::size_t channels = 0;
int plot1_traces = 0;
std::size_t plot1_samples = 0;
double buffer_mb = 0.0;
double span_s = 0.0;
std::uint64_t time_stride = 1;
bool have_event = false;
double since_event_s = 0.0;
int reconnects = 0;
};
/// The bar: "live · 10.0 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode and
/// nothing else ever. The badge glyph is a separate widget and is not part of this string.
std::string format_glance(const StatusSnapshot& s);
/// The popover's prose and the exit dump's line, field for field as the old single-line bar
/// wrote it. CLAUDE.md asserts those fields are read out of the dump, so the field names and
/// their order must not change without updating it.
std::string format_detail(const StatusSnapshot& s);
/// A sample rate as "10.0 kS/s". Moved here from ui.cpp, which still calls it.
std::string format_rate(double hz);
/// An elapsed time, stepping to a coarser unit rather than growing digits, so its width
/// cannot track the stream. Moved here from ui.cpp.
std::string format_elapsed(double seconds);
} // namespace udpscope
```
- [ ] **Step 2: Move the two formatters and write the two new ones**
Create `src/status_text.cpp`. Move the bodies of `format_rate` (currently `src/ui.cpp:881`) and
`format_elapsed` (`src/ui.cpp:812`) here **verbatim** — do not reimplement them, and do not
change their output by a character, because the dump's assertions read what they produce. Then:
```cpp
// SPDX-License-Identifier: MIT
#include "status_text.hpp"
#include <cstdio>
namespace udpscope {
// ... format_rate() and format_elapsed(), moved verbatim from ui.cpp ...
std::string format_glance(const StatusSnapshot& s) {
std::string out = s.state_word;
out += " · ";
out += s.rate_text.empty() ? "no data" : s.rate_text;
out += " · ";
out += s.window_text;
out += " window";
if (s.trigger_on) {
out += " · ";
if (s.have_event) {
out += "event " + format_elapsed(s.since_event_s) + " ago";
} else {
out += "no event yet";
}
}
return out;
}
std::string format_detail(const StatusSnapshot& s) {
char buf[1024];
std::snprintf(buf, sizeof(buf),
"[%s] %s %s | rate %s | packets %llu frames %llu gaps %llu | %zu signals, "
"%zu channels | plot 1: %d trace(s), %zu samples | buffer %.1f MB, %.2f s "
"span, time x%llu",
s.state_detail.c_str(), s.connected ? "connected" : "waiting",
s.have_config ? "configured" : "-",
s.rate_text.empty() ? "--" : s.rate_text.c_str(),
static_cast<unsigned long long>(s.packets),
static_cast<unsigned long long>(s.frames),
static_cast<unsigned long long>(s.gaps), s.signals, s.channels,
s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
static_cast<unsigned long long>(s.time_stride));
std::string out = buf;
if (s.trigger_on) {
out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago"
: " | no event yet";
}
if (s.reconnects > 0) {
char note[48];
std::snprintf(note, sizeof(note), " | %d reconnects", s.reconnects);
out += note;
}
return out;
}
} // namespace udpscope
```
Before building, **diff this against the live `std::snprintf` at `src/ui.cpp:5216`** and fix any
divergence in the format string: that line is the contract the dump's assertions were written
against. Two differences are deliberate and must be preserved exactly as written above. The live
code's `rate` local arrives already carrying its `"rate "` prefix and reads `"rate --"` when no
channel has a figure; `rate_text` carries neither prefix nor sentinel, because `format_glance()`
tests it for emptiness to print `"no data"` — so `format_detail()` writes the literal `rate ` and
substitutes `"--"` itself. And the live string ends with the `since`/`reconnects_note` suffixes
spliced into the `snprintf` arguments; here they are appended afterwards, producing the same
bytes in the same order.
Add `src/status_text.cpp` to `add_executable` in `CMakeLists.txt`, before `src/shortcuts.cpp`.
- [ ] **Step 3: Fill the snapshot in `tick()`**
In `src/ui.cpp`, add `#include "status_text.hpp"` and delete the `format_rate` and
`format_elapsed` definitions (the header now declares them, and the three remaining call sites
— `:1806`, `:5073-5074`, `:5190-5191` — keep compiling unchanged).
Replace the status composition block (`src/ui.cpp:5124-5234`) so that it fills a snapshot
rather than a string. Keep every existing computation that block already performs, unchanged and
in place: the `mode` prose (`:5125-5144`), the badge block (`:5146-5167`), the `bound`/`samples`
loop (`:5169-5177`) and the rate collapse (`:5181-5192`). Delete only the three pieces that
*formatted* the old single line — the `since` string (`:5197-5204`), the `reconnects_note` buffer
(`:5208-5213`) and the `snprintf` into `status` (`:5215-5226`) — since `format_detail()` now
produces all three. Then fill the snapshot from the locals those blocks leave behind; every
right-hand side below is an expression that already exists in `tick()` at this point:
```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<int>(st.reconnects.load(std::memory_order_relaxed));
const std::string glance = format_glance(snap);
app->status_detail = format_detail(snap);
if (app->status_detail.size() > app->status_detail_max) {
app->status_detail_max = app->status_detail.size();
}
if (glance.size() > app->glance_max_chars) app->glance_max_chars = glance.size();
if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) {
const std::string with_notice = app->notice + " | " + glance;
gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str());
} else {
app->notice.clear();
gtk_label_set_text(GTK_LABEL(app->status_label), glance.c_str());
}
gtk_widget_set_tooltip_text(app->status_label, app->status_detail.c_str());
if (app->status_popover != nullptr &&
gtk_widget_get_visible(app->status_popover) == TRUE) {
update_status_popover(*app, snap);
}
```
Two small edits the fill above depends on. The rate collapse at `src/ui.cpp:5181-5192` currently
writes its own `"rate "` prefix, and its only consumer was the `snprintf` this step deletes —
`format_detail()` writes the prefix now and `format_glance()` tests the string for emptiness, so
drop the prefix from both branches: the initialiser becomes `std::string rate;` (empty, not
`"rate --"`) and the assignment becomes
`rate = hi - lo <= 0.01 * hi ? format_rate(hi) : format_rate(lo) + ".." + format_rate(hi);`.
And `app->rows` is a pointer in `tick()` (`app->rows->row(0)` at `:5172`), hence
`app->rows->time_window()` above.
The notice prefix and the `notice_until_us` expiry keep their existing behaviour exactly — the
notice is prefixed to the *glance* line now, which is where the user is looking. The existing
rate tooltip is superseded by the detail tooltip; delete the `rate_tip` computation and its
`gtk_widget_set_tooltip_text` at `:5234` rather than setting the tooltip twice.
`state_word` is the one new computation. Derive it from the same conditions the `mode` prose
already branches on, in the same order, so the two cannot disagree:
```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), "<b>%s</b>", kHeadings[i]);
gtk_label_set_markup(GTK_LABEL(heading), markup);
gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
gtk_label_set_yalign(GTK_LABEL(heading), 0.0f);
gtk_grid_attach(GTK_GRID(detail_grid), heading, 0, kHeadingRow[i], 1, 1);
app.popover_label[i] = heading;
}
for (int i = 0; i < 7; ++i) {
GtkWidget* value = gtk_label_new("");
gtk_label_set_xalign(GTK_LABEL(value), 0.0f);
gtk_grid_attach(GTK_GRID(detail_grid), value, 1, i, 1, 1);
app.popover_label[4 + i] = value;
}
gtk_popover_set_child(GTK_POPOVER(app.status_popover), detail_grid);
app.status_info_button = gtk_menu_button_new();
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(app.status_info_button),
"help-about-symbolic");
gtk_menu_button_set_popover(GTK_MENU_BUTTON(app.status_info_button), app.status_popover);
gtk_widget_set_tooltip_text(app.status_info_button, "Stream and buffer detail");
gtk_widget_set_has_tooltip(app.status_info_button, TRUE);
```
Pack `app.status_info_button` at the **end** of the status row so it sits at the far right, with
the status label expanding to fill the space before it (the label is already ellipsized, which
is what keeps its text out of the row's minimum width). The value rows and the heading rows
share grid rows 0, 3, 5 and 6: the headings span nothing extra, the values occupy rows 0-6, so
`Stream` names rows 0-2, `Buffer` rows 3-4, `Plot 1` row 5 and `Reconnects` row 6.
Then the updater, in the anonymous namespace:
```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<unsigned long long>(s.packets),
static_cast<unsigned long long>(s.frames),
static_cast<unsigned long long>(s.gaps));
gtk_label_set_text(GTK_LABEL(app.popover_label[5]), buf);
std::snprintf(buf, sizeof(buf), "%zu signals · %zu channels", s.signals, s.channels);
gtk_label_set_text(GTK_LABEL(app.popover_label[6]), buf);
std::snprintf(buf, sizeof(buf), "%.1f MB · %.0f s span · time x%llu", s.buffer_mb, s.span_s,
static_cast<unsigned long long>(s.time_stride));
gtk_label_set_text(GTK_LABEL(app.popover_label[7]), buf);
const std::string stored = format_rate(s.stored_rate_hz);
const std::string wire = format_rate(s.declared_rate_hz);
std::snprintf(buf, sizeof(buf), "stored %s (wire %s)", stored.c_str(), wire.c_str());
gtk_label_set_text(GTK_LABEL(app.popover_label[8]), buf);
std::snprintf(buf, sizeof(buf), "%d traces · %zu samples", s.plot1_traces, s.plot1_samples);
gtk_label_set_text(GTK_LABEL(app.popover_label[9]), buf);
std::snprintf(buf, sizeof(buf), "%d", s.reconnects);
gtk_label_set_text(GTK_LABEL(app.popover_label[10]), buf);
}
```
- [ ] **Step 6: Re-point the two dump reads**
At `src/ui.cpp:5730`, replace:
```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=<N> detail_fields=11 popover_rebuilds=0` with **`N` under about 60** and `popover_rebuilds=0`.
- `mode with trigger on:` and `mode after go-live:` still carrying **every** field they carry today — `packets`, `frames`, `gaps`, the signal and channel counts, `plot 1:`, `buffer … time x…`. A short four-field value there means Step 6 was not applied.
- `badge frames: lit=… dim=… idle=…` with both `lit` and `dim` non-zero (the badge was untouched and must still blink).
- `x-desync 0` and `axes.w changes:` at 0 or a handful near the start — the status row changed shape, and this is the check that it did not start driving the plot's layout.
- `gaps 0`.
Click-free verification of the popover is not possible headlessly, so `popover_rebuilds=0` plus
`detail_fields=11` is the state assertion; the labels' content is covered by `format_detail()`
carrying the same figures into the dump.
- [ ] **Step 10: Commit**
```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 `<file>` line and one `kPages` row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from `parsed`.
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at configure time (so `add_executable` can name the file) and again on every build through `udpscope_build_info` (so the commit and the timestamp belong to the binary that is running). `git` absent leaves `kCommit` at `"unknown"` and the build succeeds.
- **The keyboard zoom's modifiers are not the wheel's.** `+`/`-` with **Ctrl** restricts to x and with **Alt** to y, while the wheel keeps upstream's shift = x / ctrl = y. Shift is unusable on the keyboard path: `key_from()` folds `'+'` and `'='` onto `kKeyZoomIn` because `+` *is* shift+`=` on common layouts, so a bare zoom-in already arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither, which needs no special case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` / `alt_plus_zooms_only_y` plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`.
- [ ] **Step 3: Document the features in README.md**
Add to the feature narrative, in the file's existing voice:
- The About dialog (version, commit, build timestamp, the settings directory and this session's file).
- The shortcut overview, with the full key table and the note that the keyboard zoom's Ctrl/Alt is a **deliberate deviation** from the shift convention the wheel uses, and why.
- The 14-page help window, and that the pages are compiled into the binary because udpscope is run straight out of the build tree with no install step.
- The two-tier status bar: what moved off the bar, what the ⓘ popover shows, and that the exit dump still prints the full detail string so the documented assertions did not change.
- [ ] **Step 4: Clean from-scratch build**
```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`.