Compare commits
17
Commits
No files matched your search
@@ -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
|
||||
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
|
||||
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** —
|
||||
@@ -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
|
||||
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.
|
||||
- `[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 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
|
||||
`toolbar icons:` under Verification): a resource path that was never added does not raise an
|
||||
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
|
||||
`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
|
||||
@@ -1279,4 +1305,48 @@ far the largest file.
|
||||
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
|
||||
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.
|
||||
+53
-2
@@ -1,5 +1,5 @@
|
||||
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_REQUIRED ON)
|
||||
@@ -12,6 +12,15 @@ endif()
|
||||
|
||||
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 ----------------
|
||||
set(PLOT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||
set(PLOT_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
@@ -51,9 +60,46 @@ add_custom_command(
|
||||
COMMENT "Compiling udpscope icon resources"
|
||||
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 ----------------------------------------------------------
|
||||
add_executable(udpscope
|
||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
||||
${UDPSCOPE_BUILD_INFO}
|
||||
src/main.cpp
|
||||
src/config.cpp
|
||||
src/channel.cpp
|
||||
@@ -66,11 +112,16 @@ add_executable(udpscope
|
||||
src/source_dialog.cpp
|
||||
src/measure.cpp
|
||||
src/math_channel.cpp
|
||||
src/status_text.cpp
|
||||
src/shortcuts.cpp
|
||||
src/help_window.cpp
|
||||
src/ui.cpp
|
||||
)
|
||||
|
||||
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")
|
||||
target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic)
|
||||
endif()
|
||||
@@ -17,6 +17,11 @@ cmake --build build
|
||||
--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`
|
||||
(size of the sample buffer, default 200), `--max-window` (largest time
|
||||
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
|
||||
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
|
||||
|
||||
These are known deviations, found by a spec/code review, that are deliberate for
|
||||
|
||||
@@ -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
|
||||
")
|
||||
Vendored
+1
-1
Submodule deps/plotter updated: 0ee6e10c81...26a7c82a80.
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 + (+/-)`.
|
||||
@@ -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
|
||||
@@ -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>.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,32 @@
|
||||
<b>Rows, layout and drag & drop</b>
|
||||
|
||||
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
|
||||
time axis and one left gutter, so their y labels line up and their windows cannot drift apart.
|
||||
|
||||
<b>How to reach it</b>
|
||||
|
||||
Drag a signal's chip out of the side panel (<tt>s</tt>) and drop it on a row to add a trace.
|
||||
Press <tt>l</tt>, or pick <b>Layout editor</b> from the overflow menu, to add and remove rows:
|
||||
a <tt>×</tt> appears at the top left of each row and a <tt>+</tt> on each boundary between
|
||||
rows, including above the first and below the last. The editor's own button in the top-right
|
||||
corner leaves it.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
|
||||
traces already on a row the drop is refused.
|
||||
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
|
||||
puts what row 1 plotted onto row 2 and leaves the new row empty — the rows are not
|
||||
renumbered under you.
|
||||
• A trace is remembered by the signal's <i>name</i>, not by its channel number, because a new
|
||||
CONFIG or an enable renumbers the channels and an index would silently re-point the trace.
|
||||
• Adding or removing a row rebuilds the figure, which is cheap, but it also re-deals every
|
||||
trace. A frozen trigger event survives the rebuild: the pinned window is restated rather
|
||||
than re-framed on the live edge.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
With three traces on row 1, pressing <tt>l</tt> shows 3 buttons (one <tt>×</tt>, two
|
||||
<tt>+</tt>). Clicking the <tt>+</tt> above row 1 gives 2 rows and 5 buttons, with the three
|
||||
traces now on row 2 — the layout probe reports exactly this as
|
||||
<tt>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</tt>.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>.
|
||||
@@ -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><host>_<port>.toml</tt>.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• What a session persists: the time window, the rows and each row's slot signals, the
|
||||
calibration, which signals are enabled, the math channels with their graphs, and the
|
||||
measurements pane.
|
||||
• Signal identity is the <i>name</i> throughout, because a new CONFIG renumbers the channels.
|
||||
• A file written by a run against a source whose streamer was down would name no signals at all,
|
||||
so such a run does <b>not</b> write: a configured session is never overwritten by a run that
|
||||
configured nothing.
|
||||
• The multicast group and the interface live <i>inside</i> the file, because its name cannot
|
||||
carry them. A file from an older build has neither key and loads as unicast on the default
|
||||
route.
|
||||
• The startup chooser lists only files that name at least one signal, which is what keeps it the
|
||||
sources you configured rather than every port you have ever tested against.
|
||||
• Deleting a file is a supported way to start a source over; the chooser's <tt>×</tt> does
|
||||
exactly that.
|
||||
|
||||
<b>Example</b>
|
||||
|
||||
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1 and set a 2 s window.
|
||||
<tt>~/.config/udpscope/127.0.0.1_8000.toml</tt> now holds <tt>rows=1 window_s=2</tt> with
|
||||
<tt>signals = ["sine", "ramp", "noise", ""]</tt>, and the next run reports
|
||||
<tt>layout loaded from …/127.0.0.1_8000.toml</tt> with those signals already acquired.
|
||||
@@ -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>.
|
||||
@@ -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>
|
||||
@@ -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), §ion, §ion_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), §ion, §ion_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
|
||||
@@ -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
|
||||
@@ -31,14 +31,6 @@ std::string sanitize(const std::string& host) {
|
||||
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) {
|
||||
// **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
|
||||
@@ -114,6 +106,14 @@ bool parse_string_array(const std::string& in, std::vector<std::string>& out) {
|
||||
|
||||
} // 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) {
|
||||
const std::string dir = config_dir();
|
||||
mkdirs(dir);
|
||||
|
||||
@@ -81,6 +81,10 @@ bool forget_source(const KnownSource& src);
|
||||
std::string math_node_text(const MathNode& node);
|
||||
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.
|
||||
/// Characters unsafe in a filename are replaced in the host part.
|
||||
std::string layout_path(const std::string& host, std::uint16_t port);
|
||||
|
||||
@@ -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
|
||||
@@ -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,6 +5,7 @@
|
||||
|
||||
#include "layout_store.hpp"
|
||||
|
||||
#include <adwaita.h>
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <cstdio>
|
||||
@@ -351,7 +352,10 @@ bool choose_source(Config& cfg) {
|
||||
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;
|
||||
c.cfg = &cfg;
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -15,13 +15,18 @@
|
||||
#include <gtk4/gtk_plot_area.hpp>
|
||||
|
||||
#include "acquisition.hpp"
|
||||
#include "build_info.hpp"
|
||||
#include "config.hpp"
|
||||
#include "export.hpp"
|
||||
#include "help_window.hpp"
|
||||
#include "layout_store.hpp"
|
||||
#include "shortcuts.hpp"
|
||||
#include "status_text.hpp"
|
||||
#include "measure.hpp"
|
||||
#include "model.hpp"
|
||||
#include "plot_host.hpp"
|
||||
|
||||
#include <adwaita.h>
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <algorithm>
|
||||
@@ -479,6 +484,24 @@ struct App {
|
||||
std::string dbg_mode_live;
|
||||
int dbg_toggles_visible = 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 ----------------------------------------------------------
|
||||
@@ -806,23 +829,6 @@ const char* badge_markup(BadgePhase phase) {
|
||||
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
|
||||
/// `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;
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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);
|
||||
}
|
||||
|
||||
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 -------------------------------------------------------
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// --- 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 ---------------------------------------------------------
|
||||
|
||||
gboolean tick(gpointer user) {
|
||||
@@ -5031,10 +5086,8 @@ gboolean tick(gpointer user) {
|
||||
// line is the receipt for both.
|
||||
static gint64 next_channels = 0;
|
||||
const gint64 now_c = g_get_monotonic_time();
|
||||
/// Filled only on a measuring tick, and applied to the status label's tooltip after the
|
||||
/// model lock is dropped: the status line can only carry the aggregate rate, so the
|
||||
/// per-channel breakdown goes where there is room for it.
|
||||
std::string rate_tip;
|
||||
// Per-channel rate computed only on the measuring tick; the detail tooltip on the label
|
||||
// now carries the full status string instead of a per-channel breakdown.
|
||||
if (app->last_stored.size() != model.channel_count()) {
|
||||
// Seed new entries with the current count: a fresh entry must not be counted
|
||||
// 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;
|
||||
app->stored_rate[c] = per_s;
|
||||
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 plot::Range y = buf.size() > 0 ? buf.y_bounds() : plot::Range{0.0, 0.0};
|
||||
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
|
||||
// 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.
|
||||
std::string rate = "rate --";
|
||||
std::string rate;
|
||||
if (!app->stored_rate.empty()) {
|
||||
double lo = app->stored_rate.front();
|
||||
double hi = lo;
|
||||
@@ -5187,51 +5238,70 @@ gboolean tick(gpointer user) {
|
||||
hi = std::max(hi, r);
|
||||
}
|
||||
// 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)
|
||||
: format_rate(lo) + ".." + format_rate(hi));
|
||||
rate = hi - lo <= 0.01 * hi ? 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
|
||||
// frozen picture apart from a stale one: the view looks identical whether the last
|
||||
// event was a second or an hour ago.
|
||||
std::string since;
|
||||
if (trigger_on) {
|
||||
since = app->last_latch_us != 0
|
||||
? " | last event " +
|
||||
format_elapsed(double(g_get_monotonic_time() - app->last_latch_us) / 1.0e6) +
|
||||
" ago"
|
||||
: " | no event yet";
|
||||
// Derive the one-word state for the glance line from the same conditions the mode prose
|
||||
// branches on, so the two cannot disagree.
|
||||
const char* state_word = "live";
|
||||
if (display == DisplayMode::Capturing) {
|
||||
state_word = "capturing";
|
||||
} else if (display == DisplayMode::Held) {
|
||||
state_word = "event held";
|
||||
} else if (trigger_on && !armed) {
|
||||
state_word = "disarmed";
|
||||
} else if (display == DisplayMode::Waiting) {
|
||||
state_word = "armed";
|
||||
} else if (paused) {
|
||||
state_word = "paused";
|
||||
}
|
||||
|
||||
// Only when it has happened: a stream that is fine carries no counter, so the line
|
||||
// stays as short as it was, and a client being kicked shows the number it was kicked.
|
||||
char reconnects_note[48];
|
||||
reconnects_note[0] = '\0';
|
||||
if (st.reconnects.load(std::memory_order_relaxed) > 0) {
|
||||
std::snprintf(reconnects_note, sizeof(reconnects_note), " | %llu reconnects",
|
||||
static_cast<unsigned long long>(st.reconnects.load(std::memory_order_relaxed)));
|
||||
udpscope::StatusSnapshot snap;
|
||||
snap.state_detail = mode;
|
||||
snap.state_word = state_word;
|
||||
snap.connected = st.connected.load();
|
||||
snap.have_config = st.have_config.load();
|
||||
snap.trigger_on = trigger_on;
|
||||
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) {
|
||||
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());
|
||||
} else {
|
||||
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;
|
||||
app->dbg_tick_ms_sum += 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_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),
|
||||
app.model->config().trigger_holdoff_s);
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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*;
|
||||
/// 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,
|
||||
@@ -5542,6 +5677,24 @@ GtkWidget* build_toolbar(App& 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), 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();
|
||||
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
|
||||
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_halign(app->status_label, GTK_ALIGN_FILL);
|
||||
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_bottom(status_row, 2);
|
||||
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));
|
||||
rebuild_signal_panels(*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));
|
||||
}
|
||||
|
||||
@@ -5727,7 +5936,7 @@ void on_activate(GtkApplication* application, gpointer user) {
|
||||
/// exist, then close the window. Runs after the reveal animations have settled.
|
||||
gboolean exit_probe_step3(gpointer 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;
|
||||
a->window = nullptr; // stop the tick before the widgets go
|
||||
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.
|
||||
gboolean exit_probe_step2(gpointer 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) {
|
||||
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;
|
||||
@@ -7163,9 +7372,159 @@ gboolean measures_probe_step(gpointer user) {
|
||||
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,
|
||||
RowSet& rows) {
|
||||
(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.model = &model;
|
||||
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")) {
|
||||
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
|
||||
// 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_trigger_time_valid);
|
||||
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
|
||||
// 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.
|
||||
|
||||
Reference in new issue
Block a user