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>
2798 lines
127 KiB
Markdown
2798 lines
127 KiB
Markdown
# 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 `&`, `<`, `>`. 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 & 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><host>_<port>.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`.
|