17 Commits
Author SHA1 Message Date
Martino FerrariandClaude Opus 4.6 8a37e9d412 docs: correct the shortcut and help entry points, and the rate example
The whole-branch review found README describing a toolbar "?" button that
does not exist, Ctrl+? for an overview bound to Ctrl+/, bare `e` for an
export bound to Ctrl+E, and a help window that opens "on the page nearest
to what the user was doing" when it unconditionally selects Overview. All
four would send a reader looking for something that is not there. The key
table also claimed to be full while omitting F1, Ctrl+/ and Ctrl+Q, and
did not say that `f` is the plot's own key rather than an app accelerator.

format_rate() is %.3g, so it prints "10 kS/s"; the two doc comments in
status_text.hpp and the example in help page 01 still said "10.0 kS/s" —
the same error already corrected in 14-status.txt and CLAUDE.md, left in
the header that declares the function and in a second user-facing page.

close_help_windows()'s comment described only gtk_window_close(), and
show_shortcuts()'s called the overview "hand-rolled"; the Adwaita rework
made one half-true and the other false.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 19:50:40 +02:00
Martino FerrariandClaude Opus 4.6 ccb4964182 docs: help, shortcuts, About and the two-tier status bar
Adds the four new features to README.md (About dialog, shortcut overview
with Ctrl/Alt key table and deviation note, 14-page help window, two-tier
status bar), adds the four new probes and six new invariants to CLAUDE.md,
and fixes the `10.0 kS/s` rate-format error in src/help/14-status.txt
(format_rate() uses %.3g, which prints `10 kS/s`).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 16:04:41 +02:00
Martino FerrariandClaude Opus 4.6 8242812338 feat: two-tier status bar, one glance line with the detail behind an info popover
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 15:51:42 +02:00
Martino FerrariandClaude Sonnet 4.6 f9490061a1 feat: keyboard accelerators for panes, trigger, export and the help windows
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-04 15:39:46 +02:00
Martino FerrariandClaude Opus 4.6 d65a121a9a refactor: build the shortcut overview from AdwShortcutsDialog
The hand-rolled grid existed to avoid GTK's deprecated GtkShortcutsWindow;
libadwaita 1.8's AdwShortcutsDialog is the supported replacement, so use the
platform widget and get its search, sectioning and shortcut labels for free.

The table strings become GTK accelerator syntax, since the shortcut label parses
them. The six mouse rows cannot: no accelerator syntax names a wheel or a drag,
so they carry an empty accelerator and prose in the new subtitle field.

adw_init() is called in run_ui() and in choose_source() (replacing its
gtk_init()), which means the whole app is drawn with libadwaita's stylesheet.
Verified settled: viewport.w 870..870, axes.w changes 2, x-desync 0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 15:34:03 +02:00
Martino FerrariandClaude Sonnet 4.6 018b98eddf feat: keyboard and mouse shortcut overview window
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-04 15:15:47 +02:00
Martino Ferrari 3e0d0de325 docs: help pages for export, sessions and the status bar 2026-10-04 15:09:05 +02:00
Martino Ferrari ba4de92f7a docs: help pages for cursors, measurements and math channels 2026-10-04 15:05:00 +02:00
Martino Ferrari e5efd54752 docs: help pages for the time window, navigation, pause and trigger 2026-10-04 15:01:01 +02:00
Martino Ferrari 04b6181926 feat: paged help window with the four Getting started pages
The pages are Pango markup in a GResource, bundled for the same reason the icons
are: udpscope runs straight out of the build tree with no install step, so a
runtime search path's failure mode would be a silently empty help window.
--c-name=udpscope_help keeps the two generated bundles from colliding.

Every page is validated with pango_parse_markup() at load and falls back to a
one-line stand-in rather than rendering raw markup or nothing. show_help() reuses
help_window.cpp's existing close-request handler, and closes via
gtk_window_close() so that handler runs and the pointer is nulled.

UDPSCOPE_PROBE_HELP_MS loads and validates every page, opens the help and About
windows, closes both and reports; parsed must equal pages.
2026-10-04 14:53:20 +02:00
Martino Ferrari 856f538681 test: explain why the zoomkeys probe reads its axes synchronously
The probe's doc-comment advised running it inside a pinned state "so the _held
readings mean the other axis did not move" — but the reads happen in the same
callback as the key events, so no frame can intervene and nothing can re-frame.
Worse, the advice hid the real reason the read is synchronous, which is the
opposite of what the pan and zoom probes do and so invites a future reader to
"fix" it: hold_pinned_window() clamps the x span into the pinned window, so a
reading taken a frame later would report that clamp's answer rather than the
restriction's for the ctrl case. Name the actual bug class a deferred read would
confuse it with, and point at the probe that does assert figure adoption.

std::fabs for the two span comparisons, matching the file's existing idiom —
<cstdlib> is also in scope here.
2026-10-04 12:14:00 +02:00
Martino Ferrari dc7821a504 test: probe the ctrl/alt keyboard zoom restriction through the app 2026-10-04 12:09:27 +02:00
Martino Ferrari 0112999e6a chore: bump deps/plotter for ctrl/alt keyboard zoom 2026-10-04 12:03:03 +02:00
Martino FerrariandClaude Sonnet 4.6 a0fa845b58 feat: About dialog with version, commit, build time and settings path
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-10-04 11:52:43 +02:00
Martino FerrariandClaude Opus 4.6 82f91d00c0 docs: implementation plan for the help/shortcuts/status work
Eleven tasks over the approved spec: build info and About, the plotter's
ctrl/alt axis-restricted zoom, 14 help pages, the accelerator table and
overview window, the app key controller, and the two-tier status bar.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 11:48:06 +02:00
Martino Ferrari f7e5d99ae8 max holdoff 1h 2026-10-04 11:43:26 +02:00
Martino FerrariandClaude Opus 4.6 6b148f82a6 docs: design for the About/shortcuts/help windows and a two-tier status bar
Four features the user asked for together: an About dialog (version, build
timestamp, settings path), a shortcut overview covering the keyboard and the
mouse, a fourteen-page help window, and a status bar split into a short glance
line plus a details popover so the plot's own row stops being crowded.

Records one deviation from the approved design: keyboard zoom-x/zoom-y use
ctrl/alt rather than shift, because `+` *is* shift+`=` on the common layouts and
a bare zoom-in therefore already arrives with Shift set.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-10-04 11:08:23 +02:00
33 changed files with 5050 additions and 89 deletions

No files matched your search

+70
View File
@@ -24,6 +24,18 @@ form for a new host. Every probe below passes `--server`, so none of them sees i
(`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a (`PLOT_BUILD_GTK4`); its tests and examples are forced off. `deps/udpsclient` is a
vendored C99 source pair compiled into a static lib. vendored C99 source pair compiled into a static lib.
**libadwaita ≥ 1.8 is a system dependency** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`),
for exactly one widget: the shortcut overview is an `AdwShortcutsDialog`. GTK4's own
`GtkShortcutsWindow` is deprecated as of 4.18 and that is its replacement. Two consequences
worth knowing before editing. `adw_init()` is called — in `run_ui()` and, since the chooser
runs first and must be styled the same, in `choose_source()` in place of its `gtk_init()`
(both are idempotent) — so **the whole app is drawn with libadwaita's stylesheet**, not
GTK's: a metric change there reaches every widget, and `layout over N frames` /
`axes.w changes` is what says whether the layout still settles. And an `AdwDialog` is
**not** a `GtkWindow`: `show_shortcuts()` keeps an `AdwDialog*`, clears it on `::closed`
(there is no `::close-request`) and closes it with `adw_dialog_close()`. Presenting it on
this app's plain `GtkApplicationWindow` is supported and warning-free; it was checked.
`deps/plotter` is upstream's own repository and this app's figure support was added `deps/plotter` is upstream's own repository and this app's figure support was added
there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the there rather than worked around here: `Figure::set_min_y_gutter()` (a floor under the
common left gutter), and in the GTK4 adapter `PlotArea`'s **figure mode** — common left gutter), and in the GTK4 adapter `PlotArea`'s **figure mode** —
@@ -552,6 +564,10 @@ Two lines of the self-check output are assertions in disguise:
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read. tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
- `[help] pages=14 parsed=14 shortest='Overview' 1381 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 (the same one the About dialog shows). `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. Two of the three are `GtkWindow`s and close with `gtk_window_close()`; the shortcut overview is an `AdwDialog` and closes with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
- `[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. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
- `[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 *zoomed* axis is the opposite failure, the key doing nothing. Ctrl restricts to x and Alt to y; Ctrl+Alt zooms neither (no special case needed — neither flag alone fires).
- `status bar: glance=[● live · 1.09 MS/s · 2 s window] max_glance_chars=53 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, the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), 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. The glance rate format matters: `%.3g` drops the trailing zero that a `%.4g` or a fixed-precision format would keep — a rate that reads differently in the dump and in the help page is a format disagreement, and `14-status.txt`'s example must match it.
The probes are the regression net — when changing UI or plot-host behaviour, extend The probes are the regression net — when changing UI or plot-host behaviour, extend
the probe/dump in `src/ui.cpp` rather than adding a separate harness. the probe/dump in `src/ui.cpp` rather than adding a separate harness.
@@ -1253,6 +1269,16 @@ far the largest file.
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see `gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
`toolbar icons:` under Verification): a resource path that was never added does not raise an `toolbar icons:` under Verification): a resource path that was never added does not raise an
error, it draws `image-missing`. error, it draws `image-missing`.
- **A shortcut table row's `accel` is GTK accelerator syntax, and a pointer gesture has
none.** `src/shortcuts.cpp`'s strings are handed to `AdwShortcutsItem`, which parses them
with `gtk_accelerator_parse()` — so they are `"space"`, `"<Control>e"`, `"<Control>slash"`,
`"plus minus"` (a space-separated pair is the "either of these" form the label draws as
`+ / -`), never the prose `"Ctrl+E"` or `"+ / -"`. Neither GTK's syntax nor libadwaita's
knows a **wheel or a drag**: `"Wheel"` costs a `Failed to parse` warning and draws an empty
cell. Those six rows therefore carry `accel = ""` and put the prose in `subtitle`, which is
the one field that is free text. A row added with a parse error is otherwise silent — the
overview still opens and the row is simply blank — so read the probe run's stderr for
`Failed to parse` after touching either table.
- **The chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries - **The chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries
`server_specified`, set only by the `--server` branch, and `main()` asks only when it is false — `server_specified`, set only by the `--server` branch, and `main()` asks only when it is false —
so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and
@@ -1279,4 +1305,48 @@ far the largest file.
out — a source whose streamer was merely down during one run would silently vanish from the out — a source whose streamer was merely down during one run would silently vanish from the
only list it can be picked from, for ever. Keeping the old file loses nothing: a run that only list it can be picked from, for ever. Keeping the old file loses nothing: a run that
configured nothing had nothing worth persisting. configured nothing had nothing worth persisting.
- **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.
- **The About dialog, the help window and the shortcut overview are opened by `show_about()`,
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** The help window
and the About dialog are `GtkWindow`s: they close with `gtk_window_close()`, which emits
`close-request` so the handler that nulls the file-scope pointer runs. `gtk_window_destroy()`
does not emit that signal and must not be used — the math dialog fell into exactly this trap,
and the pointer then points at a dead widget until the next attempt to open the same window.
The shortcut overview is an `AdwDialog` — it is **not** a `GtkWindow` — closed with
`adw_dialog_close()` and cleared on `::closed` (there is no `::close-request` on `AdwDialog`).
One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its close handler, is the shape that
prevents the double-open defect across all three.
- **`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 About dialog and the
`[help]` probe both read `kVersion`, `kCommit` and `kTimestamp` from it; `settings=` in the
probe reads `config_dir()`, which is the path the session file is written to.
- **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 convention. The
two cannot agree: Shift is unusable on the keyboard path because `key_from()` folds `'+'` and
`'='` onto `kKeyZoomIn` — `+` *is* Shift+`=` on common layouts, so a bare zoom-in already
arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special
case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y`
tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`.
- **A help page is one `.txt`, one `<file>` line in the GResource manifest, 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`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
- **`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.
- Every source file carries an SPDX MIT header; match it in new files. - Every source file carries an SPDX MIT header; match it in new files.
+53 -2
View File
@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.24) cmake_minimum_required(VERSION 3.24)
project(udpscope LANGUAGES C CXX) project(udpscope VERSION 0.1.0 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -12,6 +12,15 @@ endif()
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
# --- libadwaita: the shortcut overview ---------------------------------------
# GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and libadwaita's
# `AdwShortcutsDialog` (1.8) is its replacement, so the overview is built from the
# platform widget rather than from a hand-rolled grid. This is the only thing libadwaita
# is used for; `adw_init()` is called for it, which also means the app picks up
# libadwaita's stylesheet.
find_package(PkgConfig REQUIRED)
pkg_check_modules(ADWAITA REQUIRED IMPORTED_TARGET libadwaita-1>=1.8)
# --- deps/plotter: built from the submodule, GTK4 adapter only ---------------- # --- deps/plotter: built from the submodule, GTK4 adapter only ----------------
set(PLOT_BUILD_TESTS OFF CACHE BOOL "" FORCE) set(PLOT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
set(PLOT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) set(PLOT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
@@ -51,9 +60,46 @@ add_custom_command(
COMMENT "Compiling udpscope icon resources" COMMENT "Compiling udpscope icon resources"
VERBATIM) VERBATIM)
# --- 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)
# --- 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)
# --- the application ---------------------------------------------------------- # --- the application ----------------------------------------------------------
add_executable(udpscope add_executable(udpscope
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
${UDPSCOPE_BUILD_INFO}
src/main.cpp src/main.cpp
src/config.cpp src/config.cpp
src/channel.cpp src/channel.cpp
@@ -66,11 +112,16 @@ add_executable(udpscope
src/source_dialog.cpp src/source_dialog.cpp
src/measure.cpp src/measure.cpp
src/math_channel.cpp src/math_channel.cpp
src/status_text.cpp
src/shortcuts.cpp
src/help_window.cpp
src/ui.cpp src/ui.cpp
) )
target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads) target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads
PkgConfig::ADWAITA)
add_dependencies(udpscope udpscope_build_info)
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic) target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic)
endif() endif()
+69
View File
@@ -17,6 +17,11 @@ cmake --build build
--multicast 239.1.2.3 --interface 10.0.0.9 # multicast --multicast 239.1.2.3 --interface 10.0.0.9 # multicast
``` ```
**System dependency: libadwaita ≥ 1.8** (`pkg_check_modules(ADWAITA ... libadwaita-1>=1.8)`).
It is used for exactly one widget — the shortcut overview is an `AdwShortcutsDialog` — because
GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and `AdwShortcutsDialog` is its
replacement.
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb`
(size of the sample buffer, default 200), `--max-window` (largest time (size of the sample buffer, default 200), `--max-window` (largest time
window the toolbar offers, default 600), `--holdoff` (default trigger hold-off), window the toolbar offers, default 600), `--holdoff` (default trigger hold-off),
@@ -1209,6 +1214,70 @@ on the live ring too. So exporting during a capture writes the partly filled win
is on screen (`time` from `-pre` up to wherever the post side has reached), not the is on screen (`time` from `-pre` up to wherever the post side has reached), not the
previous event. previous event.
**The About dialog** (overflow menu) reports the running binary's version, the git commit it was
built from, the build timestamp, the settings directory and this session's layout file. Version,
commit and timestamp come from `src/build_info.cpp`, generated by CMake at configure time and
regenerated on every build — so the commit and the timestamp always belong to the binary that is
running, and `git` absent leaves the commit as `"unknown"` without breaking the build.
**The shortcut overview** (overflow menu, or Ctrl+/) lists all keyboard and mouse controls
in two groups — application actions and plot controls — and is an `AdwShortcutsDialog` rather
than GTK4's deprecated `GtkShortcutsWindow`. libadwaita ≥ 1.8 is therefore a hard dependency;
`CLAUDE.md` records the one consequence worth knowing before editing (the stylesheet it installs
reaches every widget). The full key table:
| key | action |
|---|---|
| `a` | arm / disarm the trigger |
| `Space` | pause / resume |
| `t` | show / hide the trigger panel |
| `c` | show / hide cursors |
| `m` | show / hide measurements |
| `g` | show / hide math channels |
| `l` | show / hide the layout editor |
| `s` | show / hide the side panel |
| `f` | fit to data (the plot's own key, not an application accelerator) |
| `Ctrl+E` | export visible range |
| `F1` | open the help window |
| `Ctrl+/` | open the shortcut overview |
| `Ctrl+Q` | quit |
| `+` / `-` | zoom in / out (both axes) |
| `Ctrl+` / `Ctrl-` | zoom in / out (x axis only) |
| `Alt+` / `Alt-` | zoom in / out (y axis only) |
The wheel uses Shift = x / Ctrl = y, while the keyboard uses Ctrl = x / Alt = y. The two
cannot agree: `+` *is* Shift+`=` on common layouts, so a bare zoom-in already arrives with
`Mod::Shift` set, and using Shift for axis restriction would make x-only zoom unreachable from
the keyboard. This is a **deliberate deviation from the wheel's convention**, documented in the
shortcut overview itself.
The six "Plot mouse" rows (wheel, drag, right-click) carry no accelerator key — GTK's shortcut
syntax has no member for pointer gestures — and put the prose in the `subtitle` field instead,
which `AdwShortcutsItem` renders as free text.
**The 14-page help window** (overflow menu or F1) is a scrollable text view that opens on the
Overview page, so it never comes up blank: the help pages
are compiled into the binary as a GResource (`src/help/udpscope_help.gresource.xml`,
`--c-name=udpscope_help`) for the same reason the icons are — udpscope is run straight out of the
build tree with no install step, so a search path would have to be found at runtime and a missing
file fails silently. The pages are Pango markup (`.txt` bodies), validated at load; a page that
fails validation renders a one-line stand-in so the window always opens rather than crashing.
`UDPSCOPE_PROBE_HELP_MS` verifies that `parsed == pages` and that the `closed ok` line carries
no `Gtk-CRITICAL`.
**The status bar is two tiers.** The bar itself shows one glance line: the activity badge, the
display state (one word from a fixed set), the stored rate and the window. Everything else —
packet and frame counts, gaps, buffer size and span, per-plot trace counts — is behind the ⓘ
button at the bar's right-hand end, opening a popover. The two moved there because a bar that
carried all of them was too wide to read while watching a signal, and because the bar sits
directly above the plot: a field whose width tracked the stream would relayout the plot on every
update. The rate field uses `%.3g` (`10 kS/s`, never `10.0 kS/s`), and the elapsed-time field
steps to a coarser unit rather than growing digits.
The exit dump still prints the full detail string (`mode with trigger on:` / `mode after
go-live:`) because those strings come from `format_detail()` — the same path the popover
reads — not from the label, which no longer carries those fields.
## Review findings not (yet) implemented ## Review findings not (yet) implemented
These are known deviations, found by a spec/code review, that are deliberate for These are known deviations, found by a spec/code review, that are deliberate for
+38
View File
@@ -0,0 +1,38 @@
# 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
")
+1 -1
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,436 @@
<!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
# About, shortcuts, help, and a two-tier status bar
udpscope has grown a lot of behaviour that is only written down in `CLAUDE.md` and `README.md`:
signals start disabled, a long window is decimated rather than refused, a measurement's rulers
are stepped because the pass is throttled, an export is the stored samples and not the LOD
buckets. None of it is reachable from the application. The plot's own keyboard and mouse
gestures are likewise undocumented in the app, and there is no way to tell which binary is
running. Meanwhile the status bar carries eleven fields on one line and is unreadable at a
glance.
This adds four things: an About dialog, a shortcut overview, a paged help window, and a
two-tier status bar. The first three are windows that explain the application and share a
content source; the fourth is independent, and is here because it is the same kind of work on
the same surface.
## Scope
In: the three windows, the accelerator table behind two of them, app-level keyboard shortcuts,
one addition to `deps/plotter`'s `input::Controller` (axis-restricted keyboard zoom), and the
status bar's split into a glance line and a details popover.
Out: a translated help (the app is English-only throughout), a search box over the help, a
preferences dialog, and any change to what the status line *measures* — the fields move, none
is added or deleted.
## The About dialog
Three facts, as asked: version, build timestamp, and the path of the persistent settings.
**Version** is hand-maintained, `project(udpscope VERSION 0.1.0 LANGUAGES C CXX)`. Not
`git describe`: the repository has no tags, so `describe` degrades to a bare hash and presents
it as a version number.
**Build timestamp and provenance** come from a generated translation unit. `src/build_info.hpp`
declares three constants and a CMake custom command writes `build_info.cpp` defining them on
**every** build:
```cpp
namespace udpscope::build_info {
extern const char* const kVersion; ///< project(VERSION)
extern const char* const kCommit; ///< git describe --always --dirty, or "unknown"
extern const char* const kTimestamp; ///< "YYYY-MM-DD HH:MM UTC"
}
```
`__DATE__`/`__TIME__` are not used: they only move when that translation unit happens to
recompile, so a binary built today can honestly report last week. Regenerating unconditionally
costs one small compile and one relink per build, and it follows the pattern already in
`CMakeLists.txt:43-52`, where `udpscope_resources.c` is a generated source compiled into the
executable. The commit is included because it is the fact that actually answers "which binary
is this"; a timestamp alone does not. `kCommit` reads `unknown` when `git` is absent or the
tree is not a checkout — a build from a tarball must not fail for it.
**The settings path** is `layout_store::config_dir()`, the same function the session files use,
followed by `layout_path(host, port)` for the source this run is connected to — the file a user
actually wants to find. Both as selectable labels, so they can be copied.
The widget is a `GtkAboutDialog`. It is **not** deprecated in GTK 4.22 (it is
`GtkShortcutsWindow` that is), it gives the version field, a comments block and an MIT licence
section for free, and the two paths go in the comments block, which is the only part with room
for them.
## Keyboard shortcuts
### The plot's axis-restricted zoom, in `deps/plotter`
`input::Controller` has `+`/`-` zooming **both** ranges about the plot centre, and no way to
zoom one axis from the keyboard. It gains that, with the modifier convention the box-zoom drags
already use:
| keys | effect |
| --- | --- |
| `+` / `-` | zoom both ranges about the plot centre (unchanged) |
| `ctrl` + `+` / `-` | zoom the x axes only |
| `alt` + `+` / `-` | zoom the y axes only |
**Not `shift`, and this is the one deliberate deviation from the approved design.** On the
common layouts `+` *is* shift+`=`, and `key_from()` (`adapters/gtk4/gtk_input.hpp:258`) folds
both onto `kKeyZoomIn` precisely for that reason — so a bare zoom-in keystroke already arrives
with `Mod::Shift` set, and `shift + (+/-)` cannot be distinguished from `+`. Making Shift mean
x-only would turn every plain zoom into an x-only zoom. Ctrl and Alt are unambiguous for keys,
and they are already the controller's drag convention (`ctrl`+drag = x only, `alt`+drag = y
only), so the keyboard and the mouse-drag rows of the overview read the same. The **wheel**
keeps its own `shift`=x / `ctrl`=y: that is upstream behaviour with upstream tests, and the
overview shows both honestly rather than hiding one.
The change is smaller than it looks. `KeyEvent` already carries `mods`
(`include/plot/input/events.hpp:75`) and `PlotArea::on_key_pressed` already sets
`e.mods = mods_from(state)` (`adapters/gtk4/gtk_plot_area.cpp:862`), so no adapter change, no
new key constant, and no `key_from()` change: the whole change is the `if (zooms)` block of
`Controller::on_key`, which gains the same `has_mod(e.mods, Mod::Ctrl)` / `Mod::Alt` read the
drag branch makes at `src/input/controller.cpp:493-495` and skips the axis class the modifier
excluded. A comment on `kKeyZoomIn` records why Shift is not available here, so the next person
to reach for it finds the reason.
Two tests in the controller's existing suite — `ctrl_plus_zooms_x_only_and_leaves_y` and
`alt_plus_zooms_y_only_and_leaves_x` — then the submodule commit and the pin move.
### The application's keys
| keys | action |
| --- | --- |
| `Space` | pause / resume |
| `a` | arm / disarm the trigger |
| `t` | trigger panel |
| `c` | cursors pane |
| `m` | measurements pane |
| `g` | math pane |
| `l` | layout editor |
| `s` | side panel |
| `Ctrl+E` | export the visible range |
| `F1` | help |
| `Ctrl+slash` | shortcut overview |
| `Ctrl+Q` | quit |
Bare letters for the toggles, Ctrl for the things that touch files or windows. The plot's own
keys — `+`, `-`, `f`, the arrows, `Escape` — are not in this set and are not shadowed.
One `GtkEventControllerKey` on the toplevel, in the **bubble** phase. That is what makes a
focused widget win its own keys: the plot area returns `FALSE` for everything `key_from()` maps
to `0`, which is every letter in this table and `Space` too, so they bubble up to this
controller. A focused `GtkEntry` or `GtkSpinButton` consumes its printable keys first, so
typing into the window entry or a math field is unaffected. A focused `GtkButton` consumes
`Space` to activate itself, so `Space` pauses only when focus is not on a button — ordinary GTK
behaviour, and the right outcome. The handler additionally refuses every app key while the
focused widget is a `GtkEditable`: belt and braces, and the property a probe can assert, since
a probe calls the handler directly and so bypasses the bubbling the first guarantee rests on.
### The overview window
Hand-rolled, not `GtkShortcutsWindow`: that widget was deprecated in GTK 4.18, this project
builds `-Wall -Wextra -Wpedantic` clean, and 4.22 is what is installed.
A `GtkWindow` holding a `GtkScrolledWindow` over one two-column `GtkGrid` per section: the
accelerator on the left in a monospace face, the description on the right. Sections:
**Acquisition**, **Panes and editors**, **Files and windows**, **Plot keyboard**, **Plot
mouse**. The mouse section is the controller's gesture table in prose — `shift`/`ctrl`/`alt` +
drag for the three box zooms, drag and right-drag to pan, middle-drag on each label strip for
an axis zoom, the wheel with its two modifiers, horizontal wheel to pan x, double-click to
re-fit.
## The accelerator table
`src/shortcuts.hpp` holds **one** table, and both consumers read it:
```cpp
enum class AppAction { PauseToggle, TriggerArm, TriggerPanel, CursorsPane, MeasuresPane,
MathPane, LayoutEditor, SidePanel, Export, Help, Shortcuts, Quit };
struct Accel {
unsigned keyval; ///< GDK keyval
unsigned mods; ///< GdkModifierType mask, 0 for a bare key
AppAction action;
const char* accel_text; ///< what the overview prints
const char* description;
const char* section;
};
```
`ui.cpp`'s handler looks up `(keyval, mods)` in this table to get an `AppAction` and switches on
that; the overview renders `accel_text` and `description` from the same rows. A key that works
and a key that is documented therefore cannot drift apart — which is the failure this project
has hit before in another form, two comments in `build_toolbar()` disagreeing with each other
and one of them with reality.
The mouse gestures are a second, display-only table in the same file — a gesture text and a
description, with no keyval, since nothing dispatches them — because they are rendered by the
same window and belong beside the keys they are compared against.
## The help window
A `GtkWindow` split in two: a `GtkListBox` of page titles on the left, a `GtkScrolledWindow`
holding one wrapping `GtkLabel` on the right. Selecting a row swaps the label's markup. Opened
by `F1` and from the overflow menu; **non-modal**, so it can sit beside the scope while a user
follows it.
Each page is a file of **Pango markup** in a GResource (`src/help/*.txt`, bundled by
`src/help/udpscope-help.gresource.xml`, compiled by the same `glib-compile-resources` custom
command pattern the icons use). Pango markup rather than Markdown because `GtkLabel` renders it
natively — bold, italic and monospace spans with no parser to write — and a malformed span is
caught by `pango_parse_markup()` at load time rather than drawn wrong. A GResource rather than
files beside the binary for the reason already recorded for the icons: udpscope is run straight
out of the build tree and has no install step, so a search path would have to be located at
runtime and the failure mode of not locating it is silent.
A page's title, resource path and section header are one entry in a static table in
`help_window.cpp`, so the order on screen is explicit in code rather than implied by file names.
The section headers are non-selectable rows in the `GtkListBox`.
### Page shape
Every page has the same four parts:
1. what the feature does,
2. how to reach it,
3. **Worth knowing** — the surprising behaviour, distilled from the invariants in `CLAUDE.md`
and `README.md`,
4. **Example** — one worked case with real numbers taken from the probes, not round ones.
The approved template, which every other page follows:
```
Time window
───────────
The window is how much time the plot shows. Set it from the header bar's
window button: type a number, pick a unit, or step a decade with the arrows.
Every row shares one window. The trigger's event span follows it, and the
pre-trigger share is a share of *this* window.
Worth knowing
A window too long to store at the wire rate is decimated, not refused. The
shared clock takes a power-of-two stride; each group of samples is
*averaged*, never strided, so a tone above the stored Nyquist is attenuated
instead of aliasing to a false low frequency.
Moving the window across a decimation boundary rebuilds the buffer and drops
the history.
Example
1 MS/s, 200 MB of buffer. A 2 s window stores every sample (time x1). Widen
to 600 s and the stride becomes x16: 625 S/s stored, anti-aliased at
312.5 Hz.
```
### The pages
**Getting started**
1. **Overview** — what each part of the window is: toolbar, side panel, plot rows, status bar.
The map the other pages refer back to.
2. **Choosing a source** — the startup chooser, why the list shows only sessions that name a
signal, what `×` deletes, the New host… form, and that a stored multicast session reconnects
to its group and interface.
3. **Signals and calibration** — signals start **disabled** and enabling is what starts
acquisition; a signal on no plot still gets a grey chip; calibration is applied at ingest, so
changing it rebuilds the buffer and drops history.
4. **Rows, layout and drag & drop** — drag a signal's chip from the side panel onto a row to
plot it; four slots per row; a signal's colour follows the *signal*, not the slot, so the same
signal on two rows is one colour; the layout editor's `+`/`×`, and that inserting a row
carries the existing rows' contents down with them rather than renumbering under the user.
**Viewing**
5. **Time window** — the template above.
6. **Navigating the plot** — the keyboard and mouse gestures in prose, plus what a *pinned* view
is: while an event is held, a capture is filling or the view is paused, a zoom in works but
the view cannot leave the data.
7. **Pause and play** — pausing freezes the *display*, not the producer: the ring keeps filling,
the view binds a snapshot, and the axis is pinned to the snapshot's own stamps, so a window
reaching back past the oldest retained sample shows a shorter span rather than an empty
margin.
8. **Trigger** — arm/disarm, edge and level, Normal vs Single, the pre-trigger share as a share
of *this* window, the four display states (live, capturing, held, waiting), that the trigger
sees the **stored** filtered signal, and that disarming mid-capture publishes what the capture
got to.
**Analysis**
9. **Cursors** — the shared vertical pair versus the per-row rulers, the four modes, what `mix`
reads, and that a marker past the newest sample reads `--`.
10. **Measurements** — the types, the threshold share, the on-plot marks, that a pass is
throttled to a cadence so a ruler is stepped, and that an invalid pass keeps its level marks
and drops its time marks.
11. **Math channels** — a graph evaluated as an ordered list, so a cycle is impossible by
construction; a derived channel *is* a channel; the filters run at the **stored** rate, so a
cutoff moves when the window changes the decimation; the output defaults to the last node.
**Output and state**
12. **Saving and export** — `.npy` and `.csv`, the visible range at the **stored** rate and never
the LOD buckets, the `time`/`stream_time` columns, and that the table is collected at click
time rather than when the file chooser returns.
13. **Sessions and settings** — where the files live, what a session persists (window, rows,
calibration, enabled signals, math graphs, measurements), when it is written, and why a
session whose streamer was down during a run is not overwritten.
14. **Status bar and diagnostics** — the glance line, what is behind ⓘ, and what `gaps`,
`reconnects` and the starvation notice actually mean.
## The status bar
### The glance line
One row on the bar, four fields and no more:
```
● live · 10.0 kS/s · 2 s window ⓘ
```
The badge is unchanged — one glyph in three colours. The state field becomes one short word:
`live`, `paused`, `armed`, `capturing`, `event held`, `disarmed`. The long prose it carries
today (`trigger: EVENT HELD (Arm to re-arm, or 'Go live' for streaming)`) moves into the
popover. Trigger mode adds exactly one field, `· event 4.2 s ago`.
Each field's width is bounded, for the reason `CLAUDE.md` already gives for `rate` collapsing
to one figure and for `format_elapsed()` stepping units: the status row sits directly above the
plot, so a field whose width tracks the stream relayouts the plot. That is why the state field
is a word from a fixed set rather than a sentence.
The transient notice (`App::notice`) stays on the bar, prefixed to the glance line as it is
today: it is the one piece of text that must be seen without a click.
### The details popover
Behind an ⓘ `GtkMenuButton` at the end of the row, a `GtkPopover` over a `GtkGrid` of
label/value pairs:
```
Stream connected, configured
packets 717 · frames 716 · gaps 0
3 signals · 3 channels
Buffer 200.0 MB · 1677 s span · time x1
stored 10.0 kS/s (wire 10.0 kS/s)
Plot 1 3 traces · 195000 samples
Reconnects 0
```
It is rewritten **only while it is visible**. A popover nobody opened costs nothing per frame,
and a visible one cannot relayout the plot because a popover is its own surface — which is the
property that makes the full-width detail safe to show at all.
### One composition, two consumers
`src/status_text.hpp` holds a `StatusSnapshot` of the fields the tick already computes, and two
formatters:
```cpp
std::string format_glance(const StatusSnapshot&); ///< the bar
std::string format_detail(const StatusSnapshot&); ///< the popover, and the exit dump
```
`CLAUDE.md` asserts that `rate` and `last event … ago` are checked in the exit dump's
`dbg_mode_live`/`dbg_mode_triggered`, which today are `gtk_label_get_text(app->status_label)`.
The tick therefore still assembles the complete detail text every frame, into
`App::status_detail`; the dump reads **that** instead of the label, and the popover formats
from the same snapshot. The existing dump assertions keep working unchanged, and the detail is
not a second expression of fields the glance line also computes.
## Files touched
| File | Change |
| --- | --- |
| `src/build_info.hpp` | new: declares the three constants |
| *generated* `build_info.cpp` | new: defines them, rewritten every build |
| `src/shortcuts.hpp/.cpp` | new: the accelerator table and the mouse-gesture table |
| `src/help_window.hpp/.cpp` | new: `show_help_window()`, `show_shortcuts_window()`, `show_about_dialog()` |
| `src/help/*.txt`, `src/help/udpscope-help.gresource.xml` | new: the fourteen pages |
| `src/status_text.hpp/.cpp` | new: `StatusSnapshot`, `format_glance()`, `format_detail()` |
| `src/ui.cpp` | the key controller, three menu items, the ⓘ button and popover, the glance label, `App::status_detail`, the new probes |
| `CMakeLists.txt` | `VERSION`, the build-info command, the help resource, the new sources |
| `deps/plotter` | `Controller::on_key`'s zoom branch, two tests, the pin move |
| `CLAUDE.md`, `README.md` | the documentation this project requires for both |
The three windows share one translation unit because they share one purpose, the GResource
access and the accelerator table. `src/ui.cpp` is already ~7400 lines and the largest file in
the project, so none of them goes in it.
## Error handling
| Case | Behaviour |
| --- | --- |
| A help page's resource is missing | the page's label reads a one-line "this page is missing" and the probe's `pages`/`parsed` counts disagree; nothing else is affected |
| A help page's markup is malformed | `pango_parse_markup()` fails, the page falls back to the same line, and `parsed` < `pages` |
| `git` absent or not a checkout | `kCommit` is `unknown`; the build succeeds |
| An app key pressed while a `GtkEditable` has focus | refused, the widget keeps it |
| A window already open | presented, not rebuilt — each of the three keeps one static instance pointer, and closes with `gtk_window_close()`, never `gtk_window_destroy()`, so the handler that clears the pointer runs (the trap the math dialog hit) |
| The ⓘ popover opened with no CONFIG yet | the detail fields read what they read today in that state; no special case |
## Verification
Probes on stderr in the style of the existing ones, since this project's whole regression net is
those probes and `CLAUDE.md` requires extending them rather than adding a harness.
- **`UDPSCOPE_PROBE_HELP_MS`** — open all three windows, report, close them. Equal parts crash
test and content check, the way `UDPSCOPE_PROBE_MATH_MS` is:
```
[help] pages=14 parsed=14 shortest='Overview' 412 B; shortcuts: app=12 mouse=9;
about version=0.1.0 commit=53815ad built=2026-10-04 14:22 UTC
settings=/home/martino/.config/udpscope; closed ok
```
**`parsed` must equal `pages`**, which is what catches a malformed span as a number instead of
as mangled text on screen; **no page may be empty**, which catches a resource listed in the
`.xml` and never written; `settings=` must be a real path. `closed ok` with no critical is the
lifecycle assertion.
- **`UDPSCOPE_PROBE_KEYS_MS`** — fire every row of the accelerator table at the handler directly
(the way the other probes emit `::clicked`) and report the state each one moved:
```
[keys] a: armed 0->1 | space: paused 0->1 | c: cursors 0->1 | ... |
editable focused: 'm' refused=1
```
Every row must move exactly its own state and nothing else, and the last field must read
`refused=1`.
- **`UDPSCOPE_PROBE_ZOOMKEYS_MS`** — the new plotter keys through the app, inside a **pinned**
view, so run it with the pause probe, which makes a stable pin
(`UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`):
```
[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1
```
Both `_held` must be 1 and both zooms must still happen: "a pinned view is navigable within
its data" is the invariant, and a modified zoom must not become its exception.
- **The status bar**, in the exit dump:
```
status bar: glance=[● live · 10.0 kS/s · 2 s window] max_glance_chars=38
detail_fields=11 popover_rebuilds=0
```
**`max_glance_chars` must stay under a small constant** over the whole run — that is "layout
must not depend on content" read from the one label still on the bar. **`popover_rebuilds`
must be 0** on a run that never opened it.
- **The plotter's own suite** — `xvfb-run -a deps/plotter/build/debug/tests/plot_tests`, `0
failed`, with the two new controller tests.
- **A full existing run must be unchanged.** The status refactor touches the line every other
probe's dump is read beside, so one `UDPSCOPE_EXIT_AFTER_MS` run with
`UDPSCOPE_ENABLE_ALL_MS` must still report `gaps=0`, `x-desync 0`, `0 collapsed, 0 staircase`
and every toolbar icon `(ok)`.
`CLAUDE.md` gains these probes, plus invariants for the shared accelerator table, the
Shift-is-unexpressible reason behind ctrl/alt, and the one-composition-two-consumers status
rule. `README.md` gains the four features and records the deviation from the approved
`shift + (+/-)`.
+13
View File
@@ -0,0 +1,13 @@
// 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
+26
View File
@@ -0,0 +1,26 @@
<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 kS/s · 2 s window</tt>.
+31
View File
@@ -0,0 +1,31 @@
<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.
+30
View File
@@ -0,0 +1,30 @@
<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.
+32
View File
@@ -0,0 +1,32 @@
<b>Rows, layout and drag &amp; drop</b>
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
time axis and one left gutter, so their y labels line up and their windows cannot drift apart.
<b>How to reach it</b>
Drag a signal's chip out of the side panel (<tt>s</tt>) and drop it on a row to add a trace.
Press <tt>l</tt>, or pick <b>Layout editor</b> from the overflow menu, to add and remove rows:
a <tt>×</tt> appears at the top left of each row and a <tt>+</tt> on each boundary between
rows, including above the first and below the last. The editor's own button in the top-right
corner leaves it.
<b>Worth knowing</b>
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
traces already on a row the drop is refused.
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
puts what row 1 plotted onto row 2 and leaves the new row empty — the rows are not
renumbered under you.
• A trace is remembered by the signal's <i>name</i>, not by its channel number, because a new
CONFIG or an enable renumbers the channels and an index would silently re-point the trace.
• Adding or removing a row rebuilds the figure, which is cheap, but it also re-deals every
trace. A frozen trigger event survives the rebuild: the pinned window is restated rather
than re-framed on the live edge.
<b>Example</b>
With three traces on row 1, pressing <tt>l</tt> shows 3 buttons (one <tt>×</tt>, two
<tt>+</tt>). Clicking the <tt>+</tt> above row 1 gives 2 rows and 5 buttons, with the three
traces now on row 2 — the layout probe reports exactly this as
<tt>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</tt>.
+30
View File
@@ -0,0 +1,30 @@
<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.
+34
View File
@@ -0,0 +1,34 @@
<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.
+29
View File
@@ -0,0 +1,29 @@
<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.
+35
View File
@@ -0,0 +1,35 @@
<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.
+33
View File
@@ -0,0 +1,33 @@
<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>.
+39
View File
@@ -0,0 +1,39 @@
<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.
+35
View File
@@ -0,0 +1,35 @@
<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.
+31
View File
@@ -0,0 +1,31 @@
<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>.
+34
View File
@@ -0,0 +1,34 @@
<b>Sessions and settings</b>
udpscope stores one session file per source, so reopening a source gives you the scope you
closed. There is no global preferences file: the session <i>is</i> the settings.
<b>How to reach it</b>
Nothing to open — the file is written as you work. Its path is in the About dialog, and the
directory is <tt>$XDG_CONFIG_HOME/udpscope</tt>, or <tt>~/.config/udpscope</tt> when that is
unset. Each file is named <tt>&lt;host&gt;_&lt;port&gt;.toml</tt>.
<b>Worth knowing</b>
• What a session persists: the time window, the rows and each row's slot signals, the
calibration, which signals are enabled, the math channels with their graphs, and the
measurements pane.
• Signal identity is the <i>name</i> throughout, because a new CONFIG renumbers the channels.
• A file written by a run against a source whose streamer was down would name no signals at all,
so such a run does <b>not</b> write: a configured session is never overwritten by a run that
configured nothing.
• The multicast group and the interface live <i>inside</i> the file, because its name cannot
carry them. A file from an older build has neither key and loads as unicast on the default
route.
• The startup chooser lists only files that name at least one signal, which is what keeps it the
sources you configured rather than every port you have ever tested against.
• Deleting a file is a supported way to start a source over; the chooser's <tt>×</tt> does
exactly that.
<b>Example</b>
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1 and set a 2 s window.
<tt>~/.config/udpscope/127.0.0.1_8000.toml</tt> now holds <tt>rows=1 window_s=2</tt> with
<tt>signals = ["sine", "ramp", "noise", ""]</tt>, and the next run reports
<tt>layout loaded from …/127.0.0.1_8000.toml</tt> with those signals already acquired.
+36
View File
@@ -0,0 +1,36 @@
<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 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 kS/s (wire 10 kS/s)</tt>,
<tt>Plot 1 3 traces · 195000 samples</tt>, <tt>Reconnects 0</tt>.
+27
View File
@@ -0,0 +1,27 @@
<?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>
<file>05-window.txt</file>
<file>06-navigate.txt</file>
<file>07-pause.txt</file>
<file>08-trigger.txt</file>
<file>09-cursors.txt</file>
<file>10-measurements.txt</file>
<file>11-math.txt</file>
<file>12-export.txt</file>
<file>13-sessions.txt</file>
<file>14-status.txt</file>
</gresource>
</gresources>
+291
View File
@@ -0,0 +1,291 @@
// SPDX-License-Identifier: MIT
#include "help_window.hpp"
#include <adwaita.h>
#include <cstdio>
#include <string>
#include "build_info.hpp"
#include "layout_store.hpp"
#include "shortcuts.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;
GtkWidget* g_help = nullptr;
/// The shortcut overview is an `AdwDialog`, not a window, so it has its own pointer and
/// its own clearing signal (`::closed` rather than `::close-request`).
AdwDialog* g_shortcuts = nullptr;
gboolean on_window_close(GtkWindow* window, gpointer slot) {
(void)window;
*static_cast<GtkWidget**>(slot) = nullptr;
return FALSE; // let the default handler destroy it
}
void on_shortcuts_closed(AdwDialog* dialog, gpointer user) {
(void)dialog;
(void)user;
g_shortcuts = 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"},
{"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"},
{"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"},
{"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"},
};
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());
}
} // 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));
}
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));
}
namespace {
/// Appends one table row to `dialog`, opening a new section whenever the row's `section`
/// string differs from the last one. `*current` is the section being filled, `*title` the
/// name it carries; both are owned by the caller so the two tables share one run.
///
/// `accel` is handed to `AdwShortcutsItem` as an accelerator when it is non-empty and
/// `subtitle` is used when it is not, which is how a wheel or a drag gets a row: neither
/// GTK's nor libadwaita's accelerator syntax can name one, and asking them to parse
/// "Wheel" costs a parse warning and draws nothing.
void append_shortcut(AdwShortcutsDialog* dialog, AdwShortcutsSection** current,
const char** title, const char* section, const char* accel,
const char* subtitle, const char* description) {
if (*current == nullptr || std::string(*title) != section) {
*current = adw_shortcuts_section_new(section);
*title = section;
adw_shortcuts_dialog_add(dialog, *current);
}
AdwShortcutsItem* item = adw_shortcuts_item_new(description, accel);
if (subtitle != nullptr && *subtitle != '\0') {
adw_shortcuts_item_set_subtitle(item, subtitle);
}
adw_shortcuts_section_add(*current, item);
}
} // namespace
void show_shortcuts(GtkWindow* parent) {
if (g_shortcuts != nullptr) {
adw_dialog_present(g_shortcuts, GTK_WIDGET(parent));
return;
}
AdwDialog* dialog = adw_shortcuts_dialog_new();
g_shortcuts = dialog;
adw_dialog_set_title(dialog, "Keyboard and mouse shortcuts");
AdwShortcutsSection* section = nullptr;
const char* section_title = nullptr;
int accel_count = 0;
const Accel* accels = app_accels(&accel_count);
for (int i = 0; i < accel_count; ++i) {
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), &section, &section_title,
accels[i].section, accels[i].accel, "", accels[i].description);
}
int gesture_count = 0;
const Gesture* gestures = plot_gestures(&gesture_count);
for (int i = 0; i < gesture_count; ++i) {
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), &section, &section_title,
gestures[i].section, gestures[i].accel, gestures[i].subtitle,
gestures[i].description);
}
g_signal_connect(dialog, "closed", G_CALLBACK(on_shortcuts_closed), nullptr);
adw_dialog_present(dialog, GTK_WIDGET(parent));
}
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));
if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts);
}
} // namespace udpscope
+42
View File
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: MIT
#pragma once
#include <gtk/gtk.h>
#include <cstddef>
#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);
/// 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();
/// The shortcut overview: the application accelerators and the plot's own interactions,
/// in the sections the tables name. An AdwShortcutsDialog, because GtkShortcutsWindow is
/// deprecated as of GTK 4.18 and this project builds warning-free.
void show_shortcuts(GtkWindow* parent);
/// Closes whichever of the three windows are open. The About dialog and the help window are
/// GtkWindows and close with gtk_window_close(), so their close-request handlers run; the
/// shortcut overview is an AdwDialog and closes with adw_dialog_close(), which emits ::closed.
/// Either way the file-scope pointer is nulled by the handler. Used by the probe.
void close_help_windows();
} // namespace udpscope
+8 -8
View File
@@ -31,14 +31,6 @@ std::string sanitize(const std::string& host) {
return out.empty() ? "_" : out; return out.empty() ? "_" : out;
} }
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";
}
void mkdirs(const std::string& path) { void mkdirs(const std::string& path) {
// **Every** component, the last one included. The condition used to be "the path has a trailing // **Every** component, the last one included. The condition used to be "the path has a trailing
// slash", so the final directory was never created: `~/.config/udpscope` did not exist, the // slash", so the final directory was never created: `~/.config/udpscope` did not exist, the
@@ -114,6 +106,14 @@ bool parse_string_array(const std::string& in, std::vector<std::string>& out) {
} // namespace } // namespace
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";
}
std::string layout_path(const std::string& host, std::uint16_t port) { std::string layout_path(const std::string& host, std::uint16_t port) {
const std::string dir = config_dir(); const std::string dir = config_dir();
mkdirs(dir); mkdirs(dir);
+4
View File
@@ -81,6 +81,10 @@ bool forget_source(const KnownSource& src);
std::string math_node_text(const MathNode& node); std::string math_node_text(const MathNode& node);
bool math_node_from_text(const std::string& text, MathNode& out); bool math_node_from_text(const std::string& text, MathNode& out);
/// 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();
/// `~/.config/udpscope/<host>_<port>.toml`, with the directory created. /// `~/.config/udpscope/<host>_<port>.toml`, with the directory created.
/// Characters unsafe in a filename are replaced in the host part. /// Characters unsafe in a filename are replaced in the host part.
std::string layout_path(const std::string& host, std::uint16_t port); std::string layout_path(const std::string& host, std::uint16_t port);
+85
View File
@@ -0,0 +1,85 @@
// 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, "<Control>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, "<Control>slash",
"Open this shortcut overview", "Files and windows"},
{GDK_KEY_q, GDK_CONTROL_MASK, AppAction::Quit, "<Control>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.
///
/// A space-separated accelerator is the two-key alternative form the shortcut label draws
/// as `+ / -`. The mouse rows carry no accelerator at all: neither GTK's accelerator
/// syntax nor libadwaita's knows a wheel or a drag, and asking either to parse "Wheel"
/// is a parse warning and an empty cell. Those rows are prose in the subtitle instead.
constexpr Gesture kGestures[] = {
{"plus minus", "", "Zoom in or out, both axes", "Plot keyboard"},
{"<Control>plus <Control>minus", "", "Zoom the time axis only", "Plot keyboard"},
{"<Alt>plus <Alt>minus", "", "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 and wheel", "Zoom the time axis only", "Plot mouse"},
{"", "Ctrl and wheel", "Zoom the value axis only", "Plot mouse"},
{"", "Drag", "Pan", "Plot mouse"},
{"", "Ctrl and drag", "Box zoom on time", "Plot mouse"},
{"", "Alt and 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
+53
View File
@@ -0,0 +1,53 @@
// 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; ///< GTK accelerator syntax, as `gtk_accelerator_parse()` reads it
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; ///< GTK accelerator syntax, or "" for a pointer gesture
const char* subtitle; ///< prose for what no accelerator can express ("Wheel"), else ""
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
+5 -1
View File
@@ -5,6 +5,7 @@
#include "layout_store.hpp" #include "layout_store.hpp"
#include <adwaita.h>
#include <gtk/gtk.h> #include <gtk/gtk.h>
#include <cstdio> #include <cstdio>
@@ -351,7 +352,10 @@ bool choose_source(Config& cfg) {
return false; return false;
} }
gtk_init(); // `adw_init()` rather than `gtk_init()`: it does everything the latter does and also
// installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog`
// is drawn with. Both are idempotent, so `run_ui()` calling it again costs nothing.
adw_init();
Chooser c; Chooser c;
c.cfg = &cfg; c.cfg = &cfg;
+89
View File
@@ -0,0 +1,89 @@
// SPDX-License-Identifier: MIT
#include "status_text.hpp"
#include <cmath>
#include <cstdio>
namespace udpscope {
/// `12 s`, `3m 07s`, `2h 14m` -- one unit coarser as soon as the finer one would need
/// three digits, so the field never grows past six characters and the status line's shape
/// does not depend on how long the wait has been.
std::string format_elapsed(double seconds) {
if (!(seconds >= 0.0)) return "--";
char buf[32];
if (seconds < 60.0) {
std::snprintf(buf, sizeof(buf), "%.0f s", std::floor(seconds));
} else if (seconds < 3600.0) {
const int m = static_cast<int>(seconds) / 60;
std::snprintf(buf, sizeof(buf), "%dm %02ds", m, static_cast<int>(seconds) % 60);
} else {
const int h = static_cast<int>(seconds) / 3600;
std::snprintf(buf, sizeof(buf), "%dh %02dm", h, (static_cast<int>(seconds) / 60) % 60);
}
return buf;
}
/// A sample rate in the unit it reads shortest in. Rates here span six decades (a 1 Hz
/// housekeeping signal next to a 1 MS/s acquisition), so a fixed unit makes one of the
/// two unreadable.
std::string format_rate(double per_s) {
char buf[32];
if (!(per_s > 0.0)) {
return "0 S/s";
} else if (per_s >= 1.0e6) {
std::snprintf(buf, sizeof(buf), "%.3g MS/s", per_s / 1.0e6);
} else if (per_s >= 1.0e3) {
std::snprintf(buf, sizeof(buf), "%.3g kS/s", per_s / 1.0e3);
} else {
std::snprintf(buf, sizeof(buf), "%.3g S/s", per_s);
}
return buf;
}
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
+66
View File
@@ -0,0 +1,66 @@
// 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 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 kS/s" — `%.3g`, so a trailing zero is dropped and the field's width
/// cannot track the stream. 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
+448 -77
View File
@@ -15,13 +15,18 @@
#include <gtk4/gtk_plot_area.hpp> #include <gtk4/gtk_plot_area.hpp>
#include "acquisition.hpp" #include "acquisition.hpp"
#include "build_info.hpp"
#include "config.hpp" #include "config.hpp"
#include "export.hpp" #include "export.hpp"
#include "help_window.hpp"
#include "layout_store.hpp" #include "layout_store.hpp"
#include "shortcuts.hpp"
#include "status_text.hpp"
#include "measure.hpp" #include "measure.hpp"
#include "model.hpp" #include "model.hpp"
#include "plot_host.hpp" #include "plot_host.hpp"
#include <adwaita.h>
#include <gtk/gtk.h> #include <gtk/gtk.h>
#include <algorithm> #include <algorithm>
@@ -479,6 +484,24 @@ struct App {
std::string dbg_mode_live; std::string dbg_mode_live;
int dbg_toggles_visible = 0; int dbg_toggles_visible = 0;
int dbg_trigger_revealed_default = 0; int dbg_trigger_revealed_default = 0;
/// 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;
}; };
// --- small helpers ---------------------------------------------------------- // --- small helpers ----------------------------------------------------------
@@ -806,23 +829,6 @@ const char* badge_markup(BadgePhase phase) {
return "<span foreground=\"#5A5A5A\">\u25CF</span>"; return "<span foreground=\"#5A5A5A\">\u25CF</span>";
} }
/// `12 s`, `3m 07s`, `2h 14m` -- one unit coarser as soon as the finer one would need
/// three digits, so the field never grows past six characters and the status line's shape
/// does not depend on how long the wait has been.
std::string format_elapsed(double seconds) {
if (!(seconds >= 0.0)) return "--";
char buf[32];
if (seconds < 60.0) {
std::snprintf(buf, sizeof(buf), "%.0f s", std::floor(seconds));
} else if (seconds < 3600.0) {
const int m = static_cast<int>(seconds) / 60;
std::snprintf(buf, sizeof(buf), "%dm %02ds", m, static_cast<int>(seconds) % 60);
} else {
const int h = static_cast<int>(seconds) / 3600;
std::snprintf(buf, sizeof(buf), "%dh %02dm", h, (static_cast<int>(seconds) / 60) % 60);
}
return buf;
}
/// A measurement: at most two decimals, scaled by an SI prefix so the number reads in /// A measurement: at most two decimals, scaled by an SI prefix so the number reads in
/// `1..999` (0.01052 V is `10.52 mV`), and the unit appended when the stream declares one. /// `1..999` (0.01052 V is `10.52 mV`), and the unit appended when the stream declares one.
@@ -875,22 +881,6 @@ std::string format_measure(double v, const std::string& unit) {
return out; return out;
} }
/// A sample rate in the unit it reads shortest in. Rates here span six decades (a 1 Hz
/// housekeeping signal next to a 1 MS/s acquisition), so a fixed unit makes one of the
/// two unreadable.
std::string format_rate(double per_s) {
char buf[32];
if (!(per_s > 0.0)) {
return "0 S/s";
} else if (per_s >= 1.0e6) {
std::snprintf(buf, sizeof(buf), "%.3g MS/s", per_s / 1.0e6);
} else if (per_s >= 1.0e3) {
std::snprintf(buf, sizeof(buf), "%.3g kS/s", per_s / 1.0e3);
} else {
std::snprintf(buf, sizeof(buf), "%.3g S/s", per_s);
}
return buf;
}
/// A short-lived line in front of the status text: the outcome of an action that the /// A short-lived line in front of the status text: the outcome of an action that the
/// picture does not show by itself (a drop that changed nothing, an export that wrote a /// picture does not show by itself (a drop that changed nothing, an export that wrote a
@@ -4083,6 +4073,34 @@ void on_export_clicked(GtkButton*, gpointer user) {
g_object_unref(dialog); g_object_unref(dialog);
} }
void on_about_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->menu_button != nullptr) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_popdown(popover);
}
show_about(GTK_WINDOW(app->window),
layout_path(app->model->config().server, app->model->config().port));
}
void on_help_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->menu_button != nullptr) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_popdown(popover);
}
show_help(GTK_WINDOW(app->window));
}
void on_shortcuts_clicked(GtkButton*, gpointer user) {
App* app = static_cast<App*>(user);
if (app->menu_button != nullptr) {
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
if (popover != nullptr) gtk_popover_popdown(popover);
}
show_shortcuts(GTK_WINDOW(app->window));
}
// --- right-click menu ------------------------------------------------------- // --- right-click menu -------------------------------------------------------
void on_y_auto_clicked(GtkButton* button, gpointer user) { void on_y_auto_clicked(GtkButton* button, gpointer user) {
@@ -4533,6 +4551,43 @@ void report_event_zoom(App& a, const char* what) {
std::fflush(stderr); std::fflush(stderr);
} }
// --- status popover updater -------------------------------------------------
/// 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 udpscope::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 = udpscope::format_rate(s.stored_rate_hz);
const std::string wire = udpscope::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);
}
// --- the frame tick --------------------------------------------------------- // --- the frame tick ---------------------------------------------------------
gboolean tick(gpointer user) { gboolean tick(gpointer user) {
@@ -5031,10 +5086,8 @@ gboolean tick(gpointer user) {
// line is the receipt for both. // line is the receipt for both.
static gint64 next_channels = 0; static gint64 next_channels = 0;
const gint64 now_c = g_get_monotonic_time(); const gint64 now_c = g_get_monotonic_time();
/// Filled only on a measuring tick, and applied to the status label's tooltip after the // Per-channel rate computed only on the measuring tick; the detail tooltip on the label
/// model lock is dropped: the status line can only carry the aggregate rate, so the // now carries the full status string instead of a per-channel breakdown.
/// per-channel breakdown goes where there is room for it.
std::string rate_tip;
if (app->last_stored.size() != model.channel_count()) { if (app->last_stored.size() != model.channel_count()) {
// Seed new entries with the current count: a fresh entry must not be counted // Seed new entries with the current count: a fresh entry must not be counted
// as a delta from zero. // as a delta from zero.
@@ -5070,8 +5123,6 @@ gboolean tick(gpointer user) {
const double declared = info.dt > 0.0 ? 1.0 / info.dt : 0.0; const double declared = info.dt > 0.0 ? 1.0 / info.dt : 0.0;
app->stored_rate[c] = per_s; app->stored_rate[c] = per_s;
app->declared_rate[c] = declared; app->declared_rate[c] = declared;
rate_tip += (rate_tip.empty() ? "" : "\n") + info.name + ": " + format_rate(per_s) +
" stored (declared " + format_rate(declared) + ")";
const ChannelBuffer& buf = model.live().channel(c); const ChannelBuffer& buf = model.live().channel(c);
const plot::Range y = buf.size() > 0 ? buf.y_bounds() : plot::Range{0.0, 0.0}; const plot::Range y = buf.size() > 0 ? buf.y_bounds() : plot::Range{0.0, 0.0};
std::fprintf(stderr, std::fprintf(stderr,
@@ -5178,7 +5229,7 @@ gboolean tick(gpointer user) {
// The rate the buffer is actually storing at. One figure when every channel agrees and // The rate the buffer is actually storing at. One figure when every channel agrees and
// a range when they do not: a per-channel list would make the field's width depend on // a range when they do not: a per-channel list would make the field's width depend on
// the stream, and the breakdown is on the label's tooltip instead. // the stream, and the breakdown is on the label's tooltip instead.
std::string rate = "rate --"; std::string rate;
if (!app->stored_rate.empty()) { if (!app->stored_rate.empty()) {
double lo = app->stored_rate.front(); double lo = app->stored_rate.front();
double hi = lo; double hi = lo;
@@ -5187,51 +5238,70 @@ gboolean tick(gpointer user) {
hi = std::max(hi, r); hi = std::max(hi, r);
} }
// Within a percent is the same rate measured over a one-second window, not two rates. // Within a percent is the same rate measured over a one-second window, not two rates.
rate = "rate " + (hi - lo <= 0.01 * hi ? format_rate(hi) rate = hi - lo <= 0.01 * hi ? format_rate(hi) : format_rate(lo) + ".." + format_rate(hi);
: format_rate(lo) + ".." + format_rate(hi));
} }
// In trigger mode, how long ago the event on screen was captured. It is what tells a // Derive the one-word state for the glance line from the same conditions the mode prose
// frozen picture apart from a stale one: the view looks identical whether the last // branches on, so the two cannot disagree.
// event was a second or an hour ago. const char* state_word = "live";
std::string since; if (display == DisplayMode::Capturing) {
if (trigger_on) { state_word = "capturing";
since = app->last_latch_us != 0 } else if (display == DisplayMode::Held) {
? " | last event " + state_word = "event held";
format_elapsed(double(g_get_monotonic_time() - app->last_latch_us) / 1.0e6) + } else if (trigger_on && !armed) {
" ago" state_word = "disarmed";
: " | no event yet"; } else if (display == DisplayMode::Waiting) {
state_word = "armed";
} else if (paused) {
state_word = "paused";
} }
// Only when it has happened: a stream that is fine carries no counter, so the line udpscope::StatusSnapshot snap;
// stays as short as it was, and a client being kicked shows the number it was kicked. snap.state_detail = mode;
char reconnects_note[48]; snap.state_word = state_word;
reconnects_note[0] = '\0'; snap.connected = st.connected.load();
if (st.reconnects.load(std::memory_order_relaxed) > 0) { snap.have_config = st.have_config.load();
std::snprintf(reconnects_note, sizeof(reconnects_note), " | %llu reconnects", snap.trigger_on = trigger_on;
static_cast<unsigned long long>(st.reconnects.load(std::memory_order_relaxed))); snap.rate_text = rate;
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;
snap.channels = channel_count;
snap.plot1_traces = bound;
snap.plot1_samples = samples;
snap.buffer_mb = double(model.config().buffer_mb);
snap.span_s = span_s;
snap.time_stride = time_stride;
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 = udpscope::format_glance(snap);
app->dbg_glance_last = glance;
app->status_detail = udpscope::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();
char status[1024];
std::snprintf(status, sizeof(status),
"[%s] %s %s | %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%s",
mode, st.connected.load() ? "connected" : "waiting",
st.have_config.load() ? "configured" : "-", rate.c_str(),
static_cast<unsigned long long>(st.packets.load()),
static_cast<unsigned long long>(st.frames.load()),
static_cast<unsigned long long>(st.gaps.load()), signal_count, channel_count, bound,
samples, double(model.config().buffer_mb), span_s,
static_cast<unsigned long long>(time_stride), since.c_str(), reconnects_note);
if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) { if (!app->notice.empty() && g_get_monotonic_time() < app->notice_until_us) {
const std::string with_notice = app->notice + " | " + status; const std::string with_notice = app->notice + " | " + glance;
gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str()); gtk_label_set_text(GTK_LABEL(app->status_label), with_notice.c_str());
} else { } else {
app->notice.clear(); app->notice.clear();
gtk_label_set_text(GTK_LABEL(app->status_label), status); 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);
} }
if (!rate_tip.empty()) gtk_widget_set_tooltip_text(app->status_label, rate_tip.c_str());
const double tick_ms = double(g_get_monotonic_time() - tick_start_us) / 1000.0; const double tick_ms = double(g_get_monotonic_time() - tick_start_us) / 1000.0;
app->dbg_tick_ms_sum += tick_ms; app->dbg_tick_ms_sum += tick_ms;
app->dbg_tick_ms_max = std::max(app->dbg_tick_ms_max, tick_ms); app->dbg_tick_ms_max = std::max(app->dbg_tick_ms_max, tick_ms);
@@ -5272,7 +5342,7 @@ GtkWidget* build_trigger_panel(App& app) {
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_pre_spin), 50.0); gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_pre_spin), 50.0);
gtk_box_append(GTK_BOX(box), labeled_box("pre-trigger %", app.trigger_pre_spin)); gtk_box_append(GTK_BOX(box), labeled_box("pre-trigger %", app.trigger_pre_spin));
app.trigger_holdoff_spin = spin(0.0, 10.0, 0.001, 4); app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4);
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_holdoff_spin), gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_holdoff_spin),
app.model->config().trigger_holdoff_s); app.model->config().trigger_holdoff_s);
gtk_box_append(GTK_BOX(box), labeled_box("hold-off s", app.trigger_holdoff_spin)); gtk_box_append(GTK_BOX(box), labeled_box("hold-off s", app.trigger_holdoff_spin));
@@ -5437,6 +5507,71 @@ GtkWidget* build_window_control(App& app) {
return app.window_button; return app.window_button;
} }
/// 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 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:
toggle(app.pause_button);
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;
}
}
/// 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;
}
/// The header bar carries only the controls you reach for *while watching a signal*; /// The header bar carries only the controls you reach for *while watching a signal*;
/// everything you set up once lives in the overflow menu. Nine text buttons in a /// everything you set up once lives in the overflow menu. Nine text buttons in a
/// header bar is a wall of words to read past every time you want the one you need, /// header bar is a wall of words to read past every time you want the one you need,
@@ -5542,6 +5677,24 @@ GtkWidget* build_toolbar(App& app) {
g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app); g_signal_connect(app.signal_toggle, "toggled", G_CALLBACK(on_signals_toggled), &app);
gtk_box_append(GTK_BOX(menu_box), app.signal_toggle); gtk_box_append(GTK_BOX(menu_box), app.signal_toggle);
gtk_box_append(GTK_BOX(menu_box), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
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);
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);
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);
GtkWidget* popover = gtk_popover_new(); GtkWidget* popover = gtk_popover_new();
gtk_popover_set_child(GTK_POPOVER(popover), menu_box); gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
app.menu_button = gtk_menu_button_new(); app.menu_button = gtk_menu_button_new();
@@ -5645,6 +5798,53 @@ void on_activate(GtkApplication* application, gpointer user) {
gtk_widget_set_hexpand(app->status_label, true); gtk_widget_set_hexpand(app->status_label, true);
gtk_widget_set_halign(app->status_label, GTK_ALIGN_FILL); gtk_widget_set_halign(app->status_label, GTK_ALIGN_FILL);
gtk_box_append(GTK_BOX(status_row), app->status_label); gtk_box_append(GTK_BOX(status_row), app->status_label);
// Guard the construction so the counter means something: if status_popover is already
// non-null (e.g. run_ui called twice), count the extra build and avoid a leak.
if (app->status_popover != nullptr) {
++app->popover_rebuilds;
} else {
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);
gtk_box_append(GTK_BOX(status_row), app->status_info_button);
}
gtk_widget_set_margin_top(status_row, 2); gtk_widget_set_margin_top(status_row, 2);
gtk_widget_set_margin_bottom(status_row, 2); gtk_widget_set_margin_bottom(status_row, 2);
gtk_widget_set_margin_start(status_row, 6); gtk_widget_set_margin_start(status_row, 6);
@@ -5720,6 +5920,15 @@ void on_activate(GtkApplication* application, gpointer user) {
gtk_box_append(GTK_BOX(app->plot_box), make_area(*app)); gtk_box_append(GTK_BOX(app->plot_box), make_area(*app));
rebuild_signal_panels(*app); rebuild_signal_panels(*app);
g_timeout_add(kTickMs, tick, app); g_timeout_add(kTickMs, tick, app);
// 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);
gtk_window_present(GTK_WINDOW(app->window)); gtk_window_present(GTK_WINDOW(app->window));
} }
@@ -5727,7 +5936,7 @@ void on_activate(GtkApplication* application, gpointer user) {
/// exist, then close the window. Runs after the reveal animations have settled. /// exist, then close the window. Runs after the reveal animations have settled.
gboolean exit_probe_step3(gpointer user) { gboolean exit_probe_step3(gpointer user) {
App* a = static_cast<App*>(user); App* a = static_cast<App*>(user);
a->dbg_mode_live = gtk_label_get_text(GTK_LABEL(a->status_label)); a->dbg_mode_live = a->status_detail;
GtkWidget* window = a->window; GtkWidget* window = a->window;
a->window = nullptr; // stop the tick before the widgets go a->window = nullptr; // stop the tick before the widgets go
a->dbg_side_w = gtk_widget_get_width(a->side_box); a->dbg_side_w = gtk_widget_get_width(a->side_box);
@@ -5772,7 +5981,7 @@ gboolean exit_probe_step3(gpointer user) {
/// mode the way the "Go live" button does, and stop the tick. /// mode the way the "Go live" button does, and stop the tick.
gboolean exit_probe_step2(gpointer user) { gboolean exit_probe_step2(gpointer user) {
App* a = static_cast<App*>(user); App* a = static_cast<App*>(user);
a->dbg_mode_triggered = gtk_label_get_text(GTK_LABEL(a->status_label)); a->dbg_mode_triggered = a->status_detail;
if (a->rows->row(0).plot) { if (a->rows->row(0).plot) {
a->dbg_marker_x = a->rows->row(0).plot->cursor(a->rows->row(0).trigger_marker).x; a->dbg_marker_x = a->rows->row(0).plot->cursor(a->rows->row(0).trigger_marker).x;
a->dbg_marker_max = a->rows->row(0).plot->x_range(0).max; a->dbg_marker_max = a->rows->row(0).plot->x_range(0).max;
@@ -7163,9 +7372,159 @@ gboolean measures_probe_step(gpointer user) {
return G_SOURCE_REMOVE; return G_SOURCE_REMOVE;
} }
/// Drives the plot's keyboard zoom with Ctrl and with Alt and reports which axis moved.
/// The ranges are read **synchronously**, in this callback, which is deliberate and is the
/// opposite of what the pan and zoom probes do. The question here is only "does the
/// controller's modifier restriction reach the app", so the read has to see the
/// controller's own write and nothing else: `hold_pinned_window()` clamps the *x* span
/// into the pinned window, so a reading taken a frame later would report that clamp's
/// answer rather than the restriction's for the ctrl case. Nor can a same-callback read
/// drift past a state boundary, which is the hazard the read-in-the-tick rule exists for.
/// Whether a zoom the controller performed is then *adopted* by the figure is a different
/// question, and `[pan-probe]`'s `zoom-in asked=/group=/member=` line is what asserts it.
/// Pausing first is therefore not required, only tidy — it is how Task 11 runs it:
/// UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200
gboolean zoomkeys_probe(gpointer user) {
App* a = static_cast<App*>(user);
if (a->rows->row_count() == 0 || a->rows->row(0).plot == nullptr) return G_SOURCE_REMOVE;
plot::Plot& p = *a->rows->row(0).plot;
const plot::u32 ax = a->rows->row(0).axis_x;
const plot::u32 ay = a->rows->row(0).axis_y;
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::fabs(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::fabs(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;
}
/// 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;
};
// Read the lock-protected field, not the atomic mirror: on_arm_clicked() writes
// trigger().armed directly, while trigger_armed() (the atomic) is updated by the
// acquisition thread on the next packet -- reading it immediately would race.
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_menu_button_popup(GTK_MENU_BUTTON(a->window_button));
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;
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->window_button));
}
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;
}
/// 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);
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());
std::fflush(stderr);
return G_SOURCE_REMOVE;
}
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq, int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
RowSet& rows) { RowSet& rows) {
(void)argc; (void)argc;
// Installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog`
// needs to be drawn at all. Idempotent, and `choose_source()` may already have run it.
adw_init();
App app; App app;
app.model = &model; app.model = &model;
app.acq = &acq; app.acq = &acq;
@@ -7243,6 +7602,15 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
if (const char* env = g_getenv("UDPSCOPE_EXPORT_MS")) { if (const char* env = g_getenv("UDPSCOPE_EXPORT_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), export_probe, &app); g_timeout_add(static_cast<guint>(std::atoi(env)), export_probe, &app);
} }
if (const char* env = g_getenv("UDPSCOPE_PROBE_ZOOMKEYS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), zoomkeys_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), help_probe, &app);
}
if (const char* env = g_getenv("UDPSCOPE_PROBE_KEYS_MS")) {
g_timeout_add(static_cast<guint>(std::atoi(env)), keys_probe, &app);
}
// Our own options were consumed by parse_args(); GTK would reject them, so only // Our own options were consumed by parse_args(); GTK would reject them, so only
// the program name is handed to g_application_run(). // the program name is handed to g_application_run().
@@ -7338,6 +7706,9 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
app.dbg_marker_x, app.dbg_marker_max, app.dbg_trigger_time, app.dbg_marker_x, app.dbg_marker_max, app.dbg_trigger_time,
app.dbg_trigger_time_valid); app.dbg_trigger_time_valid);
std::printf("[udpscope] mode after go-live: %s\n", app.dbg_mode_live.c_str()); std::printf("[udpscope] mode after go-live: %s\n", app.dbg_mode_live.c_str());
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);
// The derived channels beside the channel they came from. A math channel defined by the pane // The derived channels beside the channel they came from. A math channel defined by the pane
// reaches the producer by exactly the path the probe uses, so this is the pipeline's own // reaches the producer by exactly the path the probe uses, so this is the pipeline's own
// assertion: `x2` must read twice its source's extremes, a low pass must read less. // assertion: `x2` must read twice its source's extremes, a low pass must read less.