Compare commits
75
Commits
a92a6b5bf0
...
0.2.1
No files matched your search
@@ -0,0 +1,100 @@
|
||||
# Final Fixes Report
|
||||
|
||||
## Fix 1 — `known_sources()` round-trip check
|
||||
**File:** `src/layout_store.cpp`
|
||||
|
||||
Changed the port parse to capture the end pointer and check `*end != '\0'`, then added a round-trip guard: `sanitize(host) + "_" + std::to_string(port) != stem` rejects any filename that `layout_path()` could not have written. This prevents `foo_8000x.toml` from being listed as `foo:8000` (whose `×` would unlink `foo_8000.toml`) and `_8000.toml` from yielding an empty host (whose `sanitize("")` round-trips to `__8000`). Also fixed include order (`<cstdio>` before `<cstdlib>` — Fix 6) and updated the `multicast`/`interface` comment to state the TOML rule directly rather than pointing at an adjacent comment (Fix 5).
|
||||
|
||||
## Fix 2 — use-after-free: form outliving chooser, unguarded timeout
|
||||
**File:** `src/source_dialog.cpp`
|
||||
|
||||
(a) Added form destruction at the top of `chooser_finish()`: null the pointer first (so `close-request` cannot re-enter), then `gtk_window_destroy()` the form. Any path that finishes the chooser while the form is up — a row click, Escape — previously left a window with four handlers bound to a dead stack frame.
|
||||
|
||||
(b) Stored the id from `g_timeout_add()` for `pick_probe` in `pick_probe_id` and called `g_source_remove(pick_probe_id)` after `g_main_loop_run()` returns (guarded on non-zero). A row picked before the timeout fires would otherwise leave an armed source on the default main context pointing at the gone frame.
|
||||
|
||||
## Fix 3 — false comment on empty-list placeholder
|
||||
**File:** `src/source_dialog.cpp`
|
||||
|
||||
Replaced the false "Not a GtkListBoxRow, so it cannot be activated..." comment with the truth: GTK4 auto-wraps any non-row widget, but the wrapped row carries no `"source-index"` data, so `g_object_get_data()` returns null and `on_row_activated`'s bounds check refuses it.
|
||||
|
||||
## Fix 4 — unreachable duplicate branch in `pick_probe()`
|
||||
**File:** `src/source_dialog.cpp`
|
||||
|
||||
Removed the second `row == nullptr` guard; the `c.known.empty()` guard above it was the only case that produced a null row from the real list. Two identical print statements for one cause made the probe output ambiguous.
|
||||
|
||||
## Fix 5 — cross-reference comment in `save_layout()`
|
||||
**File:** `src/layout_store.cpp`
|
||||
|
||||
Replaced "for the same reason as `enabled` below" with the rule itself: a bare TOML key written after a `[[table]]` header belongs to that table, with `enabled` named as the historical example of where this lost a key.
|
||||
|
||||
## Fix 6 — include ordering
|
||||
**File:** `src/layout_store.cpp`
|
||||
|
||||
Swapped `<cstdlib>` and `<cstdio>` back into alphabetical order.
|
||||
|
||||
## Fix 7 — CLAUDE.md probe text
|
||||
**File:** `CLAUDE.md`
|
||||
|
||||
Fixed `picked nothing` → `picked nothing: the list is empty` to match the exact stderr output. Fix 4 did not change the emitted text, so no further adjustment needed.
|
||||
|
||||
## Fix 8 — dead-port run must not overwrite a configured session
|
||||
**Files:** `src/ui.cpp`, `CLAUDE.md`, `README.md`
|
||||
|
||||
Added `if (app.model->signals().empty()) return;` in `save_layout_now()`, immediately after the `source_port == 0` guard. A run that receives no CONFIG has an empty signal list and must not overwrite a file that holds real signal names — doing so produces `signals = ["", ...]`, which the chooser's filter refuses, silently removing the source from the list. Also deleted all 116 flattened `~/.config/udpscope/*.toml` files (confirmed all had no named signal). Updated CLAUDE.md (chooser invariant section) and README.md (source-chooser paragraph) to document the rule.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### 1. Build
|
||||
```
|
||||
cmake --build build
|
||||
```
|
||||
Output: clean, only the three pre-existing warnings (unused `output_built`, two missing-field-initializer on `MathNode`).
|
||||
|
||||
### 2. `/tmp/check_sources` (two new assertions added)
|
||||
```
|
||||
g++ -std=c++20 -Isrc -Ideps/plotter/include /tmp/check_sources.cpp src/layout_store.cpp src/math_channel.cpp src/measure.cpp -o /tmp/check_sources
|
||||
/tmp/check_sources
|
||||
```
|
||||
Output:
|
||||
```
|
||||
a session with no named signal is not listed ok
|
||||
newest first ok
|
||||
oldest last ok
|
||||
the stored group comes back ok
|
||||
the row and signal counts are the file's ok
|
||||
the filename splits at the last underscore ok
|
||||
forget_source reports success ok
|
||||
forget_source removes exactly one ok
|
||||
foo_8000x.toml is not listed (trailing 'x' in port) ok
|
||||
_8000.toml is not listed (empty host does not round-trip) ok
|
||||
all ok
|
||||
```
|
||||
|
||||
### 3. `/tmp/check_layout_rt`
|
||||
```
|
||||
/tmp/check_layout_rt
|
||||
```
|
||||
Output: all 11 assertions `ok`.
|
||||
|
||||
### 4. End-to-end chooser + scope run
|
||||
```
|
||||
UDPSCOPE_PROBE_PICK_MS=800 UDPSCOPE_EXIT_AFTER_MS=6000 ./build/udpscope
|
||||
```
|
||||
Relevant output:
|
||||
```
|
||||
[sources] picked 127.0.0.1:8000 multicast= interface=
|
||||
[udpscope] layout loaded from /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
|
||||
[udpscope] frames=817 packets=818 gaps=0 channels=1 span=1677.722s stride=1
|
||||
[udpscope] layout over 476 frames: axes.x 135..147, axes.w 722..734, viewport.w 870..870, y-gutter 88..100, x-ticks 20..21, x-desync 0
|
||||
[udpscope] trace ink: 406 paints checked, 0 collapsed, 0 staircase, 0 short, worst cover 2.5%
|
||||
```
|
||||
|
||||
### 5. Fix 8 dead-port assertion
|
||||
```
|
||||
timeout 15 xvfb-run -a env XDG_CONFIG_HOME=/tmp/fix-e2e UDPSCOPE_EXIT_AFTER_MS=4000 ./build/udpscope --server 127.0.0.1 --port 8000
|
||||
cat /tmp/fix-e2e/udpscope/127.0.0.1_8000.toml
|
||||
```
|
||||
File mtime stayed at 10:01 (written by the live mock run); the dead-port run at 10:02 did not touch it.
|
||||
File contents: `signals = ["sine", "", "", ""]` — the configured signal name was preserved.
|
||||
+84
-4
@@ -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)
|
||||
@@ -30,7 +39,7 @@ if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
||||
target_compile_options(udpsclient PRIVATE -Wall -Wextra)
|
||||
endif()
|
||||
|
||||
# --- icons: four symbolic SVGs compiled into the binary -----------------------
|
||||
# --- icons: six SVGs compiled into the binary --------------------------------
|
||||
# There is no stock icon for an oscilloscope trigger, a pair of cursors or a measurement
|
||||
# ruler, so this app draws its own. They go in a GResource rather than beside the binary
|
||||
# because udpscope is run straight out of the build tree and has no install step: a
|
||||
@@ -39,7 +48,8 @@ endif()
|
||||
find_program(GLIB_COMPILE_RESOURCES NAMES glib-compile-resources REQUIRED)
|
||||
set(UDPSCOPE_ICON_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/icons)
|
||||
file(GLOB UDPSCOPE_ICON_FILES CONFIGURE_DEPENDS
|
||||
${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg)
|
||||
${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg
|
||||
${UDPSCOPE_ICON_DIR}/scalable/apps/*.svg)
|
||||
add_custom_command(
|
||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
||||
COMMAND ${GLIB_COMPILE_RESOURCES}
|
||||
@@ -51,9 +61,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
|
||||
@@ -63,13 +110,46 @@ add_executable(udpscope
|
||||
src/plot_host.cpp
|
||||
src/export.cpp
|
||||
src/layout_store.cpp
|
||||
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()
|
||||
|
||||
# --- the desktop entry: opt-in, never part of `cmake --build` ------------------
|
||||
# `Exec` is the built binary's absolute path in the build tree, so the entry always launches
|
||||
# exactly the binary that was last built -- and moving or deleting the build directory breaks
|
||||
# it. That is the price of having no install step, and it is the right one here: an install()
|
||||
# rule would create a second copy of the app that can diverge from the build tree, and a
|
||||
# documented `cp` would need the binary on PATH, which it is not.
|
||||
#
|
||||
# configure_file() cannot evaluate a generator expression and file(GENERATE) cannot substitute
|
||||
# an @VAR@, so the two run in sequence: the first writes the genex out literally, the second
|
||||
# evaluates it. This is the documented idiom.
|
||||
set(UDPSCOPE_EXEC "$<TARGET_FILE:udpscope>")
|
||||
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/data/org.udpscope.app.desktop.in
|
||||
${CMAKE_CURRENT_BINARY_DIR}/desktop-entry.in @ONLY)
|
||||
file(GENERATE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/org.udpscope.app.desktop
|
||||
INPUT ${CMAKE_CURRENT_BINARY_DIR}/desktop-entry.in)
|
||||
|
||||
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected.
|
||||
add_custom_target(desktop-entry
|
||||
COMMAND ${CMAKE_COMMAND}
|
||||
-DENTRY=${CMAKE_CURRENT_BINARY_DIR}/org.udpscope.app.desktop
|
||||
-DICON=${UDPSCOPE_ICON_DIR}/scalable/apps/org.udpscope.app.svg
|
||||
-DSYMBOLIC=${UDPSCOPE_ICON_DIR}/scalable/apps/org.udpscope.app-symbolic.svg
|
||||
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/install_desktop_entry.cmake
|
||||
COMMENT "Installing the udpscope desktop entry into XDG_DATA_HOME"
|
||||
VERBATIM)
|
||||
add_dependencies(desktop-entry udpscope)
|
||||
@@ -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),
|
||||
@@ -36,6 +41,15 @@ both states, the frame and per-channel sample counts, each plot's painted-frame
|
||||
count, which channel each trace slot holds, and the allocated size and reveal
|
||||
state of both side panels.
|
||||
|
||||
Every size it prints is read from a value the frame tick captured while the window was alive, not
|
||||
from the widget: the dump runs from the destroy handler, where the widget pointer is already freed
|
||||
memory. It used to ask two of them directly, which is where the long-standing "an exit-dump run
|
||||
emits exactly two `Gtk-CRITICAL`s" baseline came from — and a critical was only the lucky reading.
|
||||
`UDPSCOPE_PROBE_HELP_MS` builds and destroys three windows of its own before the dump, so by then
|
||||
the memory had been reused and the same two calls **crashed in the middle of the dump**, which
|
||||
looks like a truncated log rather than like a segfault in a debug line. The dump now makes no
|
||||
`gtk_widget_*` call at all and a clean run carries no critical.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
@@ -58,27 +72,101 @@ frame and the GUI thread takes once per frame around binding the traces.
|
||||
Samples cross the boundary through `plot::data::RingBuffer` (single producer,
|
||||
single consumer), never through the lock.
|
||||
|
||||
## Measurements
|
||||
## The x axis is relative to now
|
||||
|
||||
**A live axis is fixed at `[-window, 0]`, with the newest sample at 0.** That is what a bench
|
||||
scope in roll mode shows and what the window control's figure means: a 2 s window reads `-2s` to
|
||||
`0` and keeps reading that for as long as the stream runs, with the trace flowing right to left
|
||||
under it. The axis used to carry the stream's own clock, so the labels counted the app's uptime
|
||||
upwards (`20:00`, `20:01`, …) and the one number a user actually wants — how long ago a feature
|
||||
happened — had to be worked out by subtraction.
|
||||
|
||||
Every mode now expresses x as *seconds from the thing the view is about*, and the rule is one
|
||||
line: `display_x = stream_t - ScopeModel::stream_at_x0()`, where `stream_at_x0()` is the stream
|
||||
time of display x = 0.
|
||||
|
||||
| display mode | x = 0 is | `stream_at_x0()` |
|
||||
| --- | --- | --- |
|
||||
| `Live`, `Waiting` | the newest acquired sample | the newest stamp |
|
||||
| `Capturing` | the accepted edge | the edge |
|
||||
| `Held` | the event's edge | the snapshot's `x_offset` |
|
||||
| `Paused` | the instant of the freeze | the snapshot's `x_offset` |
|
||||
|
||||
The live answer is written by the frame tick **after** `bind()` and `acquire()`, because it has to
|
||||
be minus the newest sample the *traces* can report: asking the model before they are bound puts
|
||||
the newest data a frame's production to the right of the axis, which at 1 MS/s over a 50 ms window
|
||||
is a third of the screen. It converges in exactly one frame and cannot oscillate — `live_edge()`
|
||||
reports in the epoch currently in force, so `new = old - live_edge()` is `-raw_newest` whatever the
|
||||
old value was.
|
||||
|
||||
A snapshot carries its own offset (`EventSnapshot::x_offset`) rather than leaning on the model's
|
||||
epoch, because its coordinates outlive the epoch that produced them. One consequence is a reversal
|
||||
of an earlier deliberate choice: **a pause snapshot is now rebased** like an event's. Leaving its
|
||||
stamps on the stream's clock would have thrown the frozen picture a whole uptime to the right of an
|
||||
axis that is now newest-relative. `x_offset` keeps the stream clock recoverable, which is what the
|
||||
export's second column reads — and it fixed a latent bug on the way, the export's snapshot branch
|
||||
having added `trigger_time()` to a *paused* view's stamps: an unrelated edge, or none at all before
|
||||
the first latch.
|
||||
|
||||
Two things move in **opposite** directions under a rolling axis, and the asymmetry is deliberate.
|
||||
A **cursor** stays on screen: placed at -1.5 s it keeps meaning "1.5 s ago" and the data flows
|
||||
under it, which is a bench scope's roll behaviour and is free here, the cursors already being
|
||||
stored in display coordinates. A **measurement mark** is glued to the sample it measured, because
|
||||
a mark is a *result* rather than an instrument — so the marks are stored in stream seconds and
|
||||
reconverted every frame (`place_measure_marks()`), which is what makes them drift left with their
|
||||
own data between recomputes. `App::measure_window` is on the stream's clock for a related reason:
|
||||
"has the window moved" is the question that triggers a recompute, and a fixed `[-window, 0]` makes
|
||||
it invisible in display coordinates, so the pane would have computed once and never again.
|
||||
|
||||
**Pause freezes the picture, not the acquisition.** The producer keeps writing the live ring
|
||||
while paused — the same thing it does when the trigger is off — and the plot binds a *snapshot*
|
||||
of the window that was on screen at the pause, with the axes pinned there. That is the same
|
||||
immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`); the
|
||||
difference is the anchoring: an event's stamps are edge-relative, a pause's are the stream's own,
|
||||
so the times on the axis do not change when the pause begins. Resuming drops the snapshot and
|
||||
immutable-series path a held trigger event uses (`DisplayMode::Paused` sits beside `Held`).
|
||||
Resuming drops the snapshot and
|
||||
re-frames on the newest data, so the seconds that passed while paused are *there*, not a hole —
|
||||
which is what pausing used to leave, because it stopped the producer outright.
|
||||
which is what pausing used to leave, because it stopped the producer outright. The status bar
|
||||
says how long it has been frozen (`paused · 10 kS/s · 2 s window · for 12 s`), in **wall**
|
||||
seconds: the producer never stopped, so the stream's own clock says nothing about how long the
|
||||
user has been looking at a still picture. It is written through `format_elapsed()`, which steps to
|
||||
a coarser unit instead of growing digits, because the status row sits directly above the plot and
|
||||
a field whose width tracks the wait would relayout it.
|
||||
|
||||
**Sub-second windows read relative.** The x ticks are absolute until the window's span goes
|
||||
under a second (with a band to 1.05 s, so the form cannot flicker while a zoom is nudged), and
|
||||
relative below it: the ticks are then generated on a grid anchored at the window's left edge —
|
||||
so they read `0`, `5ms`, `10ms` instead of `12.345671s`, `12.345672s`, which three significant
|
||||
digits cannot even tell apart — and a caption names that anchor in the axes' top-left corner:
|
||||
`t0 = 12.3456 s`. The labels scale through the prefixed units the SI formatter uses — `500ms`,
|
||||
`500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of `0.005ms`. Adding the two is the tick's absolute time. The caption is an annotation
|
||||
inside the axes rather than the axis title on purpose: its text changes with the window, and a
|
||||
title of a changing width would relayout the plot every frame. Everything a user copies out —
|
||||
the cursors' `T0`/`T1`/`dT`, a measurement, an export — stays in absolute stream seconds.
|
||||
## Measurements
|
||||
|
||||
**The x ticks read relative when, and only when, they could not be written absolutely.** A
|
||||
`TickFormat::Time` label carries three significant digits, so the test is the tick **step**
|
||||
against the label's own **resolution at the axis' magnitude**: `12.345671s` and `12.345672s` are
|
||||
the same string, and there the ticks are re-anchored on a grid at the window's left edge — so
|
||||
they read `0`, `5ms`, `10ms` — with a caption naming that anchor, `t0 = 12.3456 s`. Adding the
|
||||
two is the tick's absolute time. The labels scale through the prefixed units the SI formatter
|
||||
uses — `500ms`, `500us`, `5us`, `5ns` — so a microsecond window reads in microseconds instead of
|
||||
`0.005ms`.
|
||||
|
||||
The rule used to be the *span* alone (relative under a second, with a band to 1.05 s), and a
|
||||
span says nothing about whether a label can be written: it was wrong in **both** directions. A
|
||||
2 s window at twenty minutes of uptime is labelled `20:00` on every tick — `m:ss` carries whole
|
||||
seconds — and was left absolute and unreadable. And a **triggered** sub-second window, whose
|
||||
axis is already `[-25ms, +25ms]` about the edge, reads `-20ms`, `-10ms`, `0`, `10ms` perfectly
|
||||
well, yet was re-anchored anyway: the one position a triggered axis exists to show was displaced
|
||||
by a `t0` caption that had nothing to explain. The step is read back from the majors the last
|
||||
layout produced (a function of the range and the axis width, not of the label form, so no
|
||||
feedback loop), the magnitude is the larger of the two endpoints, and the comparison carries a
|
||||
1.05 band leaving the relative form plus a ULP of slack entering it — a step *equal* to the
|
||||
resolution is readable (`1.73s`, `1.74s`, `1.75s`: one in the last digit is exactly enough), so a
|
||||
decision taken on the last bits of two differently computed doubles picked a different form from
|
||||
one frame to the next. The caption is **chrome** — `Plot::set_x_note()`, added to the plotter for
|
||||
it, drawn on the legend's own backplate in the bottom-left corner of the axes — rather than the
|
||||
axis title or an annotation. Not the title, because its text changes with the window and a title
|
||||
of a changing width would relayout the plot every frame; not an annotation, because an
|
||||
annotation's anchor is a *data* coordinate, so the caption crawled across the plot under a pan and
|
||||
drifted under a zoom, which is what it was reported as. The library test is
|
||||
`the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move`: the footprint is measured as the pixels
|
||||
a frame with the caption differs from the same frame without one, and a pan to another decade plus
|
||||
a 64× zoom must produce the same box to the pixel. Everything a user reads off the screen is in
|
||||
the *display* coordinate the labels name — the cursors' `T0`/`T1`/`dT`, a measurement's time
|
||||
marks, the crosshair — so live they are negative ages and in trigger mode they are offsets from
|
||||
the edge. Only the **export** carries the stream's own clock as well, in a second column
|
||||
(`abs_seconds`); see *Export*.
|
||||
|
||||
The anchor is taken by the *axis* on every layout (`Axis::origin_follows_range`), not written
|
||||
by the app: the window's edge is derived from the newest sample during the layout, so a value
|
||||
@@ -113,17 +201,58 @@ measurement.
|
||||
|
||||
`period` and `frequency` deliberately take **no options**: the engine counts *rising*
|
||||
crossings of the *mid* level, one edge per cycle, and a split or a side to choose from could
|
||||
only make that count wrong — a split near either extreme counts noise as crossings, and
|
||||
counting both edges halves the period.
|
||||
only make that count wrong — a split near either extreme leaves the hysteresis band below no room
|
||||
to reject noise with, and counting both edges halves the period.
|
||||
|
||||
The split is a share of the **window's own** min..max, not volts: a measurement is about this
|
||||
signal in this window, and a level in volts would have to be typed for every signal. A
|
||||
crossing has to be *strict* — a sample exactly at the level is on neither side — so a signal
|
||||
that merely touches the level does not count every sample as an event.
|
||||
signal in this window, and a level in volts would have to be typed for every signal.
|
||||
|
||||
### Why a crossing is counted with hysteresis
|
||||
|
||||
A crossing is not read off a single level. It is read off a **band**: two thresholds a tenth of
|
||||
the window's peak-to-peak either side of the split, and an edge is counted only once the signal
|
||||
has passed the far one. Inside the band a sample decides nothing. That is a Schmitt trigger, and
|
||||
it is the difference between counting the waveform's cycles and counting its noise.
|
||||
|
||||
Counting on one level was the first implementation and it fails on anything real, badly and
|
||||
silently. The mid level is exactly where a waveform moves *fastest* — on a sine the slope there
|
||||
is 2πf and the signal only clears a disturbance of amplitude *a* after a/2πf seconds — so any
|
||||
interferer faster than the fundamental recrosses the level several times per true crossing, and
|
||||
each recrossing is counted. Measured on a 5 Hz sine with a 500 Hz tone at a tenth of the
|
||||
amplitude (`tools/mock_udpstreamer.py --noise 0.1`): 9 true crossings read as **79**, giving
|
||||
`period=0.025622` and `frequency=39.029` for a true 0.2 s and 5 Hz. The number is not merely
|
||||
noisy, it is a different order. With the band the same window reads `period=0.200188`. The band
|
||||
holds to an interferer at **0.3** — a third of the waveform — where it reads `period=0.200688`,
|
||||
and to white noise at σ=0.05; at 5 % of peak-to-peak it already fails at 0.2.
|
||||
|
||||
Three things about it are deliberate, and each was wrong first.
|
||||
|
||||
- **The band moves the edge decision only.** The side the `sum`/`count` split uses stays the bare
|
||||
level, because that split *is* `top` and `bottom` — the mean of the samples above and below the
|
||||
threshold — and a band there changes a documented exact reading. `top` on a unit sine must still
|
||||
be `0.636620`, 2/π.
|
||||
- **The band is clamped to stay inside the data**, to half the room between the level and the
|
||||
nearer extreme. `pulse duration` lets the user put the split near an extreme (a 3.3 V logic
|
||||
signal with ringing wants it nearer the top), and a fixed tenth of the span there puts the upper
|
||||
threshold *above every sample in the window*: the walk never decides the upper side and the
|
||||
measurement reads nothing at all, which is strictly worse than reading something noisy. Measured
|
||||
on such a signal at a 90 % split: `enters=0` with a fixed band, `enters=4 period=0.200000` with
|
||||
the clamp.
|
||||
- **The LOD fast path tests the band and the level separately.** Tightening the bucket-fold
|
||||
condition to `level ± h` would refuse every bucket lying inside the band — and those are
|
||||
precisely the buckets a crossing passes through, so the raw walk would get a *share of the
|
||||
window* instead of a bucket or two per crossing. A bucket on one side of the **level** can always
|
||||
be binned; only a side *change* whose position is inside the bucket is refused. The clean fast
|
||||
mock still reads `raw=119412 (0.1%)` of a 215 M-sample total at 1.41 ms a recompute; with the
|
||||
0.1 interferer it is `raw=2.0%` at 1.96 ms, which is the noise's own cost and not the band's.
|
||||
|
||||
A sample exactly *at* a threshold is still on neither side, so a signal that merely touches one
|
||||
does not count every sample as an event.
|
||||
|
||||
Each measurement's row carries **two badges**: its own colour (the same one its marks use on
|
||||
the plot) and the signal's chip, in the palette colour every other view names that signal
|
||||
with. The dialog's fields and their wording follow the type — the split row is absent for
|
||||
the plot) and the signal's chip, in the colour every other view names that signal with —
|
||||
through `RowSet::signal_color()`, so a signal the user re-coloured is re-coloured here too and
|
||||
not left at the palette entry. The dialog's fields and their wording follow the type — the split row is absent for
|
||||
`average`, `min` and `max`, the side row only for `pulse duration`, and the "show on plot"
|
||||
line says what would be drawn.
|
||||
|
||||
@@ -170,18 +299,20 @@ Both walks go through it:
|
||||
the window's size. The two stamps the scan needed are read at the end rather than at every
|
||||
chunk that moved an extreme, since a stamp is a ring read and an extreme moves many times.
|
||||
- **Pass two** (the threshold types: `top`, `bottom`, `pulse duration`, `period`, `frequency`)
|
||||
folds a bucket whole whenever it lies *strictly* on one side of its level — then every sample
|
||||
in it decides the same side, none is *at* the level, and an entering edge is exactly the
|
||||
bucket's first sample, which is where the raw walk would have found it. Only the buckets that
|
||||
**straddle** a level are opened, which on a clean waveform is one per crossing.
|
||||
folds a bucket's sums whenever it lies *strictly* on one side of its level — then every sample
|
||||
in it falls in the same bin and none is *at* the level, so the fold is exact. It then asks
|
||||
separately whether the bucket *enters* a side of the hysteresis band: one wholly past a threshold
|
||||
holds that crossing at its first sample, exactly, which is where the raw walk would have found
|
||||
it. Only a bucket that straddles the level, or one that enters a side somewhere inside itself, is
|
||||
opened — on a clean waveform a bucket or two per crossing.
|
||||
|
||||
The raw path is still there and still exact, for the window's partial edge buckets, for the
|
||||
samples produced since this frame's `acquire_all()` (bounded by one frame of production), and
|
||||
for those straddling buckets. Nothing is decimated or strided: `peak to peak` still reads
|
||||
for those opened buckets. Nothing is decimated or strided: `peak to peak` still reads
|
||||
exactly `2` on a unit sine and `top` exactly `0.636620` — 2/π, the half-wave mean, which is the
|
||||
check that a threshold type is not merely plausible. A window shorter than a bucket, or a signal
|
||||
noisy enough to straddle everywhere, degrades back to the O(window) cost and nothing else
|
||||
changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly
|
||||
noisy enough to cross a threshold everywhere, degrades back towards the O(window) cost and nothing
|
||||
else changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly
|
||||
this: `raw` near `covered` means the pyramid bought nothing.
|
||||
|
||||
### What the math channels cost
|
||||
@@ -247,6 +378,80 @@ The same reason the window control is a fixed-width label in the header bar
|
||||
not expand: a header-bar child that expands is measured into the title's centre
|
||||
box, so the control stretched to the title and every window resize moved it.
|
||||
|
||||
## The icon, the About dialog and the desktop entry
|
||||
|
||||
**The icon is the instrument face**: a dark panel carrying one bold green trace. That is the
|
||||
clearest metaphor available for this program -- it is what the thing being imitated looks like --
|
||||
and it is the kind of physical object the GNOME HIG's top-and-front depth language was written
|
||||
for, so the guidelines apply as written rather than by analogy. Its silhouette is also what makes
|
||||
it survive being scaled down, which is why two alternatives were dropped: a bare waveform (no
|
||||
silhouette at all, so nothing is left of it in a dock) and a colour version of the trigger glyph
|
||||
the toolbar already carries (a *control*, not the application, and it would mean two icons that
|
||||
have to stay in step).
|
||||
|
||||
It is built to the HIG's own numbers. A 128px canvas the artwork does not fill (it spans
|
||||
x 12..116, y 22..106), every feature on the 2px grid, and **one** depth treatment: a 4px front
|
||||
profile along the bottom, clipped to the chassis silhouette so it does not protrude past the
|
||||
rounded corners. The rest is flat colour -- no gradients, no shadows -- because every surface
|
||||
here is straight, and the palette is GNOME's (Dark 3 chassis, Dark 2 profile, Dark 4 screen,
|
||||
Green 1 trace).
|
||||
|
||||
**Two things were deliberately left out.** There is no graticule: the grid lines a real scope
|
||||
face carries cannot survive 32px, and the trace carries the metaphor without them. And the trace
|
||||
is one cycle -- a single crest, a single trough -- because at 32px the screen is about 22x16 px
|
||||
and anything busier reads as a smudge.
|
||||
|
||||
The two forms are authored differently on purpose. The colour icon **strokes** its sine, which is
|
||||
the sane way to draw a smooth curve; the 16px symbolic one cannot, because GTK's symbolic
|
||||
recolour wraps the file in a stylesheet that reaches `fill` on `path`, `rect` and `circle` only,
|
||||
so a stroked line keeps the colour written in the file and stays dark on a dark theme. Its wave is
|
||||
therefore a closed 2px band -- a sine along the top edge and the same sine 2px lower along the
|
||||
bottom. Do not "fix" one to match the other.
|
||||
|
||||
Both are compiled into the binary as a `GResource` under `scalable/apps/`, for the same reason
|
||||
the four action icons are: udpscope is run straight out of its build tree and has no install step.
|
||||
The one consequence worth knowing is that a **malformed** SVG cannot be caught at runtime -- the
|
||||
icon *name* still resolves, so GTK reports the icon as present and draws nothing -- which is why
|
||||
every `<file>` in the manifest carries `preprocess="xml-stripblanks"` and the build fails with a
|
||||
file and line number (plus a caret under the offending character) instead.
|
||||
|
||||
**The About dialog is an `AdwAboutDialog`** rather than a `GtkAboutDialog`, and that migration is
|
||||
what the app has an icon *for*: the dialog shows it in its own header, which `GtkAboutDialog`
|
||||
would too but only after being handed one, and nothing else in the program had asked for a
|
||||
128px rendering. Two further gains came with it. The debug information is a **Troubleshooting
|
||||
page with a copy button**, where `system_information` on the old dialog was inert text a user
|
||||
reporting a problem had to retype. And the string it shows is a `std::string`, which removed a
|
||||
`char[1024]` that truncated a long settings path in silence -- the one failure mode of a fixed
|
||||
buffer that no one notices until the path matters.
|
||||
|
||||
What it cost: an `AdwAboutDialog` is an `AdwDialog` and **not** a `GtkWindow`, so its close path
|
||||
differs from the help window sitting beside it -- `adw_dialog_close()` and `::closed`, rather than
|
||||
`gtk_window_close()` and `::close-request`. That is the same trap the shortcut overview already
|
||||
had, and the two now share one `::closed` handler keyed by the pointer it must null. What was
|
||||
deliberately left out: credits (one author, already named), release notes (there are no releases)
|
||||
and an issue-tracker URL (the website link reaches the repository, which carries the tracker).
|
||||
|
||||
**The desktop entry is opt-in.** `cmake --build build` never touches `$XDG_DATA_HOME`; asking for
|
||||
it does:
|
||||
|
||||
```sh
|
||||
cmake --build build --target desktop-entry
|
||||
```
|
||||
|
||||
That writes `org.udpscope.app.desktop` and both app icons under `$XDG_DATA_HOME` (default
|
||||
`~/.local/share`), validates the entry with `desktop-file-validate` as a hard failure when the tool is on `PATH`, refreshes
|
||||
`update-desktop-database` and `gtk4-update-icon-cache` if they are on `PATH`, and prints the three
|
||||
paths it wrote. Those three lines **are** the uninstall instructions: there is no uninstall
|
||||
target, because three `rm`s the build already told you about are less to maintain than a target
|
||||
that has to guess what a previous version installed.
|
||||
|
||||
The caveat is the entry's `Exec`, which is the built binary's **absolute path in the build tree**:
|
||||
moving or deleting `build/` breaks the launcher. That is the price of having no install step, and
|
||||
it is the right trade here -- the entry then always launches exactly the binary that was last
|
||||
built, which for a program under development is what you want. No `StartupWMClass` is needed:
|
||||
GTK takes the WM class from the application ID, and the desktop file's basename is that same
|
||||
string, which is what associates a running window with the entry.
|
||||
|
||||
## How the spec maps onto the code
|
||||
|
||||
**One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel`
|
||||
@@ -255,7 +460,8 @@ ring per channel (N enabled signals -- see the limitation on multi-element signa
|
||||
below). The spec's second buffer is the latched `EventSnapshot`, which is already
|
||||
an immutable copy of the captured window (see "Trigger" below); a second live ring
|
||||
bought nothing and cost half the memory budget, so `--buffer-mb 200` really is
|
||||
200 MB.
|
||||
200 MB. What it *would* buy is priced in the dump's `publish cost:` line — see
|
||||
"There is one live buffer, not two" under the review findings.
|
||||
`plan_partition()` sizes the rings to cover `--max-window` seconds and then grows
|
||||
them with the rest of the budget. When a channel's declared sample rate is lower
|
||||
than the fastest channel's, it is decimated by the integer ratio
|
||||
@@ -264,7 +470,9 @@ clock is resampled too (`Partition::time_stride`, an averaging decimation — se
|
||||
"A window longer than the buffer is resampled"). Because the split is by
|
||||
*time span* rather than by bytes, every channel and the time base retain the same
|
||||
history -- which is what makes one shared time slice meaningful. With no
|
||||
decimation the two rules coincide and every slice is the same size.
|
||||
decimation the two rules coincide and every slice holds the same number of samples
|
||||
(not the same number of bytes: the shared clock is `f64` and the values are `f32`, so
|
||||
the growth loop weighs the budget in bytes -- see "The time ring is `f64`" below).
|
||||
|
||||
The ring is always filling, whatever the trigger is doing. The pre-trigger history
|
||||
has to already be there when an edge arrives; while acquisition followed the
|
||||
@@ -377,11 +585,12 @@ the post window had been waiting for. That cost one sample of twenty thousand on
|
||||
|
||||
The axis is still pinned to what the snapshot actually captured, read off its own
|
||||
stamps rather than off the request: the two can differ by a sample at each end, and
|
||||
pinning the request would claim coverage the data does not have. The window's bounds
|
||||
are rounded to the time ring's own `f32` before being compared against it, so a sample
|
||||
sitting exactly on a bound is kept whatever the stream's absolute time happens to be —
|
||||
in f64 that decision fell to how the stamp rounded, which cost a sample at 10 kS/s and
|
||||
a third of the capture at 1 S/s.
|
||||
pinning the request would claim coverage the data does not have. The cut's bounds are
|
||||
compared in the ring's own type, which is now the `f64` they arrive in — see "The time
|
||||
ring is `f64`" below. While the ring was `f32` the bounds had to be *rounded to it*
|
||||
before comparing, or a sample sitting exactly on a bound fell to how its stamp
|
||||
rounded, which cost a sample at 10 kS/s and a third of the capture at 1 S/s. That
|
||||
rule is gone with its cause.
|
||||
|
||||
**Where the capture is drawn was a separate bug from what it contained**, and it
|
||||
outlived the fixes above. The plotting library's `gather()` emits x *relative to*
|
||||
@@ -458,6 +667,33 @@ a math channel's name and that name only exists as a signal once its graph is in
|
||||
reopened on the same source comes back as the one that was closed — including a signal that was
|
||||
enabled only to feed a math channel.
|
||||
|
||||
**Launched with no `--server`, udpscope asks which source to open.** It used to connect to
|
||||
`127.0.0.1:8000` because that is the default in `Config` — fine for one streamer, useless for
|
||||
several, and there was no way to see which sources the app already knew about. The chooser lists
|
||||
one row per stored session: the server and port it was named after, how many plots and signals it
|
||||
held, and its multicast group when it had one, newest first. `New host…` is a four-field form —
|
||||
server, port, multicast group, interface — and nothing more: `--buffer-mb` and the rest are tuning
|
||||
rather than "which source", and a form that asks for them is a form you have to read before using
|
||||
the app. Cancel exits before anything is allocated, because declining to choose a source is not a
|
||||
failure.
|
||||
|
||||
It runs **before the model exists**, which is the whole reason it is cheap: no buffer, no socket
|
||||
and no window until a source is known, and `ScopeModel`, `Acquisition` and `run_ui()` are
|
||||
untouched by the feature. Every headless probe passes `--server`, so none of them reaches it.
|
||||
|
||||
Two things the list had to get right. A session file is named `<host>_<port>.toml` and that name
|
||||
cannot say whether the source was multicast or which interface it joined on, so the file now
|
||||
records both — in the top block, before the first table, because a bare key written after a
|
||||
`[[table]]` header belongs to that table in TOML, which is how the `enabled` key was silently lost
|
||||
once already. And the list is **filtered to sessions that name a signal**: a development machine
|
||||
accumulates one file per probe run on a throwaway port — 140 of them here, every one with
|
||||
`signals = ["", "", "", ""]` — so an unfiltered chooser opens as a wall of ports nobody chose. A
|
||||
row's `×` forgets a source by deleting its session file, with no confirmation: the gesture is the
|
||||
confirmation, and what it destroys is one layout the app rewrites on the next clean exit. A session
|
||||
is **not written** when no CONFIG arrived during the run — otherwise a source whose streamer was
|
||||
down during one launch would silently vanish from the chooser list because its file was flattened
|
||||
to empty signal names, which the filter refuses.
|
||||
|
||||
**Math channels.** A math channel is derived from the others by a graph of operations and is then
|
||||
an *ordinary channel*: the model synthesises a signal entry for it, so an enabled one is acquired,
|
||||
plotted, measured, exported and listed in the signal panel exactly like a streamed signal — and
|
||||
@@ -468,18 +704,25 @@ is what makes branching — the sum of a raw and a filtered channel — expressi
|
||||
|
||||
The editor is a compact node graph: each node is a box, its inputs are the squares on its left edge
|
||||
and its output the square on its right. Click a box to select it and its parameters appear in the
|
||||
form beside the canvas; click an output square and then an input square to wire them; drag a box to
|
||||
move it. The graph is a *list* walked in order, so a node can only read the ones above it — a cycle
|
||||
is not detected, it is unrepresentable, and an input square is only offered the nodes that qualify.
|
||||
The node carrying the channel's value is the **last one** of a graph built top to bottom (as the
|
||||
orange dot on the canvas says, and settable with *set as output*): it defaulted to the first node —
|
||||
the source — so every operation was computed and discarded, and a gain or a filter produced a
|
||||
channel identical to its input. Numeric fields are seeded with their own identity values too, since
|
||||
form beside the canvas; **drag from an output square to an input square to wire them** (the old
|
||||
two-click protocol is gone); drag a wired input square off its port to detach it; click a wire and
|
||||
press `Delete` to remove it; drag a box to move it. **Wheel zooms about the pointer, drag the
|
||||
background to pan, `f` to fit, `Ctrl+0` to reset.** *tidy*, a button in the editor dialog beside
|
||||
*fit*, lays out all nodes in topological columns. The op is chosen when the node is added — *add
|
||||
node* in the same row of the dialog is a menu button whose popover lists every op — so there is no
|
||||
opless placeholder to build past. The graph is a *list* walked in order, so a node can
|
||||
only read the ones above it; a wire drawn right to left re-sorts the list silently, so the graph
|
||||
stays evaluable without requiring the user to add nodes in order. A cycle is unrepresentable:
|
||||
`math_connect()` checks the trial edge set with Kahn's algorithm before writing anything. The node
|
||||
carrying the channel's value is the **last one** of a graph built top to bottom (as the orange dot
|
||||
on the canvas says, and settable with *set as output*): it defaulted to the first node — the source
|
||||
— so every operation was computed and discarded, and a gain or a filter produced a channel identical
|
||||
to its input. Numeric fields are seeded with their own identity values too, since
|
||||
`gtk_spin_button_new_with_range()` leaves a spin button at its range's minimum: the offset started
|
||||
at -1e6, which turned `x*2` into `x*2 - 2000000`.
|
||||
|
||||
Wiring is checked before the channel is created, and the reason is shown rather than the dialog
|
||||
closing on the work.
|
||||
A live validation line beneath the form says why the graph cannot be saved; Apply is insensitive
|
||||
while it is non-empty. The two modal alert boxes that used to appear are gone.
|
||||
|
||||
A `Source` node lists only the channels the scope is actually **acquiring**: naming a disabled
|
||||
signal would read as no data for ever, and the stream's timestamp signal has no samples at all. The
|
||||
@@ -685,6 +928,40 @@ signature. The same torn read still exists for a small `i > 0` — the oldest fr
|
||||
renderer's increasing-suffix filter exists to absorb, so it is left alone rather than fixed
|
||||
speculatively.
|
||||
|
||||
**The time ring is `f64`; the value rings stay `f32`.** It was `f32` like everything else,
|
||||
with the stamps stored relative to an epoch fixed at the first sample ever seen — so the
|
||||
number being stored grew for as long as the app ran while the interval it had to resolve
|
||||
stayed one sample period. `f32` carries 24 bits of mantissa, which is a *relative*
|
||||
precision, so the ULP at 16 s of uptime is 1.9 µs, at 32 s 3.8 µs, at 64 s 7.6 µs and over
|
||||
a 600 s window 61 µs. At 1 MS/s the sample period is 1 µs, so after half a minute two to
|
||||
four consecutive samples rounded onto the same stamp, after a minute eight, and so on
|
||||
without bound. That is the "zoom in to about 10 µs and there are three to five points with
|
||||
the same timestamp" report, and the export showed it unambiguously: `np.diff(t)` over two
|
||||
million rows held **three** distinct steps, 0.953 µs, 1.192 µs and 1.431 µs — exactly
|
||||
2, 2.5 and 3 ULP of the magnitude the stamps had reached, a stair instead of a clock. The
|
||||
min/max artefacts are the same cause read through the LOD pyramid: a bucket of samples
|
||||
sharing one x is a zero-width segment, so an envelope drew as a vertical spike at a
|
||||
plateau's edge rather than as the waveform.
|
||||
|
||||
Nothing was wrong with the arithmetic, so no amount of care at a call site could have
|
||||
fixed it; the type was. **A value's precision is a property of the signal and a
|
||||
timestamp's is a property of how long the scope has been running** — 24 bits is plenty for
|
||||
a sample and runs out on a clock. Widening only the clock keeps the cost where it belongs:
|
||||
the rings are the memory budget, and the values are the overwhelming majority of them. The
|
||||
one consequence worth knowing is that `plan_partition()`'s growth loop can no longer count
|
||||
slots, because a time slot and a value sample are now different sizes — it weighs the
|
||||
budget in **bytes** (`sizeof(f64)` for the shared clock plus `sizeof(f32) / stride[i]` per
|
||||
channel) and the power-of-two rounding absorbs the rest: a three-channel stream at
|
||||
`--buffer-mb 200` retains the same 8.39 s span it did before. `EventSnapshot::stamps` is
|
||||
`f64` for the same reason rather than for symmetry — an *event's* stamps are rebased onto
|
||||
its edge and so are small, but a *pause* view is deliberately not rebased and carries full
|
||||
stream time, which quantised at exactly the rate the live ring did.
|
||||
|
||||
`RingBuffer<T>` stores its slots as `std::atomic<T>` behind a
|
||||
`static_assert(is_always_lock_free)`, so this was a question worth asking rather than
|
||||
assuming: `std::atomic<double>` is lock-free on every target this app builds for, and the
|
||||
assertion is a compile-time guard rather than a silent fallback.
|
||||
|
||||
**A held event carries its own LOD pyramid**, and the lack of one is what "the plot gets
|
||||
laggy with large data" was. The renderer picks a level per trace per frame with
|
||||
`MinMaxPyramid::level_for()`, which descends to level 0 — *raw samples* — on a pyramid
|
||||
@@ -705,21 +982,128 @@ which hides exactly the spike a capture was triggered on, and since the level wa
|
||||
it never even ran. On the same repro: **138,078 segments** (43× fewer) and a 66 ms worst
|
||||
frame, with the steady-state draw around 2 ms. It costs about 1 byte per sample per
|
||||
channel (≈25 % on top of the snapshot's `f32` values) and one stall per latch, since the
|
||||
build runs under `ScopeModel::mutex()` — at 1.3 MS/s that is a ~40 ms tick, once, with no
|
||||
packet gaps.
|
||||
build runs under `ScopeModel::mutex()`. That stall is now measured rather than estimated —
|
||||
the dump's `publish cost:` line read **115 ms** for one event publish at 1.3 MS/s, against
|
||||
the ~40 ms this paragraph used to quote — and it produced no packet gaps.
|
||||
|
||||
**A paused view carries one too**, and that half was missing until it was found by its
|
||||
*appearance*. `publish_pause_view()` filled `stamps`, `values` and `y` and left `pyramids`
|
||||
empty, so a frozen pause was drawn from every raw sample it held, on every frame — the same
|
||||
defect as above with the same cause, in the other snapshot. `frame path:` read
|
||||
`raw=21 lod=118` over a 1.6 s pause against `raw=1 lod=170` for the same run live, with the
|
||||
draw mean going 12.34 → 18.18 ms and its worst frame 30.35 → 86.92 ms. But the reason it was
|
||||
noticed is that the trace *looked* different: `Renderer::stage_trace_density()` grades each
|
||||
pixel column against that column's own peak, from a `1/height` dwell deposit per segment, so
|
||||
a column fed several hundred raw inter-sample segments accumulates a far spikier
|
||||
distribution — and a far larger peak to normalise against — than one fed the one or two
|
||||
bucket envelopes the pyramid gives. The paused trace read as a hard bright core with its
|
||||
dwell tail dimmed away. The rule the invariant was written without: **a pause is not a
|
||||
cheaper case than a capture — it freezes the same rings.**
|
||||
|
||||
It is not free, and the trade is one-off against per-frame. Building the pause pyramid took
|
||||
that publish from 6.15 ms to 22.31 ms, and unlike the event publish this one runs on the
|
||||
**UI** thread and so blocks the *producer* — against ~5 ms a frame of draw for as long as the
|
||||
pause lasts, which over a 1.6 s pause at ~60 fps repays thirty times over. The producer
|
||||
absorbs it: an A/B of the same fast-mock run with and without a pause read `gaps=0` both
|
||||
ways, with the pause publish at 86 ms in the paused one. (One earlier run reported `gaps=73`
|
||||
with a pause against 0 without, which is why the stall was worth measuring at all — but it
|
||||
has not reproduced, it did not scale with the publish length, and an 86 ms publish costing
|
||||
nothing is the stronger reading. Treat that 73 as incidental load on one run.)
|
||||
|
||||
The verification that the *look* is restored is the same A/B read through `frame path:`:
|
||||
`raw=0 lod=373 columns=373 graded=373` without a pause against `raw=1 lod=555 columns=555
|
||||
graded=555` with one — the same path, the same 24711-segment worst frame and a segment mean
|
||||
within 5 % — where before the fix the paused run read `raw=21 lod=118`.
|
||||
|
||||
The self-check's two `frame cost:` / `frame path:` lines are the guard: they split a frame
|
||||
into the tick, the plot's own draw and the whole GTK paint, and report the segment counts
|
||||
and whether the LOD path was taken at all. A segment count in the millions is this defect
|
||||
returning.
|
||||
|
||||
**A dense trace is drawn one pixel column at a time, which is what cured the moiré.** At a
|
||||
high sample rate, a high signal frequency or on a noisy signal the plots showed strong,
|
||||
*crawling* banding — and three things compounded to produce it, none of which is a bug on its
|
||||
own. `MinMaxPyramid::level_for()` sizes a bucket at one to two pixel columns; `Envelope`
|
||||
emits two points per bucket, so each bucket contributes a near-vertical min→max hairline
|
||||
*and* a near-vertical connector to the next, about 3.6 near-vertical hairlines per column per
|
||||
trace; and every one of them lands at a *fractional* x, because a trace is deliberately not
|
||||
snapped. With src-alpha compositing a column's brightness is then a non-linear function of
|
||||
the sub-pixel phases overlapping it, the phase pattern repeats at the beat between the bucket
|
||||
pitch and the pixel pitch, and a pan sweeps the beat. It needs a *thin ribbon* to be visible:
|
||||
a window whose buckets each span several periods paints a saturated block and hides it
|
||||
entirely, which is why it showed up on fast and noisy signals and not on a slow sine at a
|
||||
whole-window view.
|
||||
|
||||
`deps/plotter`'s `Renderer::draw_traces` now reduces such a window onto the pixel grid: one
|
||||
exactly vertical segment per integer column, from the lowest to the highest screen y any
|
||||
gathered point put in it, flushed in framebuffer pixels with the snap on. The whole trace
|
||||
then has **one** sub-pixel phase, so there is nothing left to beat. See the upstream
|
||||
`docs/data-and-performance.md` for the four rules it keeps (a hole stays a hole, disjoint
|
||||
neighbours are joined so a fast edge keeps its join, a span under the stroke is drawn *as*
|
||||
the stroke, and the gate is the gathered density rather than the LOD level) and for why this
|
||||
is the one carve-out in `u_snap`'s rule that chrome may be snapped and data may not.
|
||||
|
||||
It is also a large speed-up, because the geometry becomes O(axes.w) instead of O(buckets).
|
||||
Measured on the fast mock (`--rate 1300000 --batch 2000 --freq 3000`, a 20 s run): `frame
|
||||
path: raw=1 lod=262 columns=0 frames, segments 10960/216093` and **263 painted frames**
|
||||
before, `raw=2 lod=1198 columns=1200 frames, segments 2041/2237` and **1201 painted frames**
|
||||
after — the same run at 4.6× the frame rate, with `trace ink:` reading `0 collapsed, 0
|
||||
staircase, 0 short, worst drawn-vs-quoted 0.0000 px` either way. `frame path:`'s
|
||||
`columns=` field is the reading that separates "the segment count collapsed because the
|
||||
reduction ran" from "the window got narrower".
|
||||
|
||||
**Those columns are then graded by dwell, which is the rest of the answer.** A reduced column
|
||||
is honest about *where* the signal went and silent about *how long it was there*: its min and
|
||||
max are the same whether its samples fill the span evenly or pile at its two ends, so a ramp
|
||||
and a pulse are one flat band. `Style::intensity_grading` — off in the library, which udpscope
|
||||
turns on at the first tick, since the renderer does not exist until the area is realized —
|
||||
draws each column graded instead: bright where the signal sits, faint where it only passes
|
||||
through, the way a digital-phosphor scope reads. The distribution cannot be recovered from the
|
||||
band, so the gather is asked at a level finer than the pixel grid and the dwell read from those
|
||||
sub-buckets; the measure is dwell *per row*, each segment depositing one unit spread over the
|
||||
rows it spans, and normalisation is against the column's own peak. See the upstream
|
||||
`docs/data-and-performance.md` for the five things that carry it and for the two that were
|
||||
wrong first (counting rows at +1 each inverts the pattern, grading a sine's fast middle as
|
||||
brightly as its rails; a frame-wide scale needs a 33 MB density image *and* fades the trace out
|
||||
exactly where it moves fastest).
|
||||
|
||||
It costs about a millisecond a frame and the A/B is the only way to read that, because a frame
|
||||
mean cannot attribute its own cost: **`UDPSCOPE_NO_PHOSPHOR=1` puts the style back to the
|
||||
library's default**, and the same probe run on the fast mock (`--rate 1300000 --batch 2000`)
|
||||
reads `draw 3.24/21.43 ms` with the grading against `draw 2.30/25.76 ms` without, over the same
|
||||
`lod=453` frames. **`frame path:`'s `graded=` must equal `columns=`** — grading is a way of
|
||||
drawing the reduction and not a third path, so the two counts agreeing is what says the look
|
||||
reaches every reduced frame (`columns=1166 graded=1166` in that run, `graded=0` under the env
|
||||
var). The segment count rises with it, from a mean of 131 to 1340, which is the gradient's
|
||||
tiers: alpha is quantised into steps because the line pipeline's colour is a uniform and a
|
||||
per-segment alpha is inexpressible, and each step is its own draw call.
|
||||
|
||||
The one performance trap here is worth stating because only the A/B found it. Asking for a
|
||||
finer level drops straight to **raw samples** once a sub-column falls under one pyramid bucket
|
||||
(64 samples), which at the run's 172 samples a pixel it did: `raw=1154 lod=0` and 6.4 ms a
|
||||
frame, i.e. grading undoing the very reduction it draws. The finer ask is floored at level 1
|
||||
whenever the flat path would have used the pyramid, so what degrades is the *look* — fewer
|
||||
sub-columns than asked for — and never the path.
|
||||
|
||||
**The panels and the toolbar.** The header bar carries only what you reach for
|
||||
*while watching a signal*: the time window, pause/resume, the trigger toggle, the
|
||||
side-panel toggle and an overflow menu. Those
|
||||
four buttons are icons with tooltips; nine text buttons was a wall of words to read
|
||||
past every time you wanted the one you needed. The overflow menu holds **Fit to
|
||||
data**, the **Layout editor**, **Cursors**, **Reset cursors**,
|
||||
**Export visible range…** and the **Signal list** toggle — the things you set up once. The side column and the two panel toggles stay hidden until a
|
||||
past every time you wanted the one you needed. The overflow menu holds **Fit to data**, the
|
||||
**Layout editor**, the **Signal list** toggle, **Export visible range...**, **Keyboard
|
||||
shortcuts**, **Help** and **About udpscope**, in that order — the shape a GNOME menu has, and
|
||||
drawn as one: every row's label starts at the same left edge (and the rows that can be checked
|
||||
keep that column whether or not they are checked), a shortcut sits at the row's right edge, a row
|
||||
is ~18 px tall rather than Adwaita's 34, and the groups are separated by rules. The ⓘ on the
|
||||
status bar opens its detail **above** the button: below is off the window's bottom edge, and a
|
||||
bubble whose body is clipped away while its top edge stays at the button is what "the arrow is
|
||||
detached from the body" looks like.
|
||||
|
||||
The **help window** (`F1`) carries the same screenshots as `docs/`: the overview page shows the
|
||||
main window and a held capture above its text, and the trigger and math pages carry theirs. A
|
||||
page's images are `GtkPicture`s **with a height request** — a shrinkable one asks for no height at
|
||||
all inside a box, so without it the page renders its text with a silent gap where the screenshots
|
||||
should be. The side column and the two panel toggles stay hidden until a
|
||||
CONFIG arrives — there is nothing to configure before the stream describes itself —
|
||||
and the first CONFIG reveals the signal panel (the trigger panel stays behind its
|
||||
own toggle).
|
||||
@@ -766,13 +1150,21 @@ connection, the measured rate, the packet/frame/gap counters, what plot 1 holds
|
||||
the buffer's size and span. Three of those are worth calling out:
|
||||
|
||||
- **The badge blinks green at 1 Hz while the pipeline is actually running** — the
|
||||
stream is connected, acquisition is not paused, and in trigger mode the trigger is
|
||||
armed. Anything else leaves it a steady grey, with the reason on its tooltip
|
||||
(`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the signal,
|
||||
not the colour: a steady badge is a stopped pipeline, and that distinction survives
|
||||
a screenshot. It is one glyph whose colour is all that changes, so blinking it
|
||||
stream is connected, a CONFIG has arrived, acquisition is not paused, and in trigger
|
||||
mode the trigger is armed. Anything else leaves it steady, with the reason on its
|
||||
tooltip (`paused`, `trigger disarmed`, `waiting for the stream`). *Blinking* is the
|
||||
signal, not the colour: a steady badge is a stopped pipeline, and that distinction
|
||||
survives a screenshot. It is one glyph whose colour is all that changes, so blinking it
|
||||
cannot change its width — a badge that grew and shrank twice a second would move the
|
||||
label beside it and re-lay out the plot with it.
|
||||
There are two steady colours, and the difference is how far the handshake got.
|
||||
**Grey** is a socket with nothing behind it, or a pipeline the user stopped.
|
||||
**Yellow** is a source that answered CONNECT but has sent **no CONFIG**: there is no
|
||||
signal list, so nothing can be enabled and no DATA frame is owed. The glance line reads
|
||||
`connecting` in both, because neither has the signal list every other state word claims;
|
||||
the colour is what separates "no socket yet" from "a socket and no stream behind it". A
|
||||
blinking green over a source that has sent nothing would claim a working pipeline, which
|
||||
is the one reading the badge exists to prevent.
|
||||
- **`rate 9.99 kS/s` is the rate the buffer actually stored over the last second**, not
|
||||
the rate the CONFIG declared. The two agree unless the requested window is too long
|
||||
to hold at the wire rate, in which case the ingest resamples (see "A window longer
|
||||
@@ -812,6 +1204,20 @@ shape and the column of chips reads as a legend. Because `plot::Plot` fixes a tr
|
||||
colour at `add_trace()`, the index is stored on the row (`Row::color_index`) and a
|
||||
colour change goes through a figure rebuild, which a drop already forces.
|
||||
|
||||
**Clicking the chip of a signal that is on a plot opens a colour chooser** (`GtkColorDialog`,
|
||||
with the alpha slider off — a trace is stroked opaque, so a transparency setting would do
|
||||
nothing), and the colour it returns *overrides* the palette entry for that signal. The chip of
|
||||
a signal on no plot is not clickable: a grey chip names no trace, so there is nothing to
|
||||
recolour. A chosen colour is held by signal **name** (`RowSet::set_signal_color()`), saved in
|
||||
the session file's `[[color]]` tables and resolved — for the trace and for every chip — by the
|
||||
one call `RowSet::signal_color()`, so a chip cannot claim a colour the trace is not drawn in.
|
||||
The way back is **Default colour** in the signal's `⋯` menu, sensitive only while an override
|
||||
exists; it is there rather than on the chip because a second gesture on a one-glyph target is
|
||||
not something anybody would find. The chooser is opened by a `GtkGestureClick` on the existing
|
||||
label rather than by wrapping it in a `GtkButton`, because a button's padding is exactly what
|
||||
made this row twice as tall once (below), and the gesture claims its sequence so a click on the
|
||||
chip cannot become the start of a row drag.
|
||||
|
||||
A `GtkSwitch` and two
|
||||
always-visible buttons made a row about twice as tall (46 px against 26 px now),
|
||||
which is what turned a four-signal stream into a panel you have to scroll; Adwaita's
|
||||
@@ -828,9 +1234,9 @@ that changes nothing still says why. Each plot's y axis has a right-click menu
|
||||
with auto scale, a fixed range, **Fit X axis**, **Fit all**, and one
|
||||
**Remove '<signal>' from plot N** entry per occupied trace slot (a plot with nothing on
|
||||
it says so instead). The two fits are the same pair the overflow menu and the `f` key
|
||||
offer, split by what they touch: **Fit X axis** frames the window on the data the ring
|
||||
holds and leaves every y scale exactly as the user set it, **Fit all** does that and
|
||||
auto-scales the y axes too.
|
||||
offer, split by what they touch: **Fit X axis** puts the x view back on the whole time
|
||||
window and leaves every y scale exactly as the user set it, **Fit all** does that and
|
||||
auto-scales the y axes too. Neither changes the time window itself — see below.
|
||||
|
||||
**The plot does not twitch.** Two independent causes were fixed, both of which
|
||||
showed up as the x axis moving a few pixels every frame — including while paused,
|
||||
@@ -846,13 +1252,40 @@ which is how the second one was found:
|
||||
what its labels need, so a floor written onto the rows would survive exactly
|
||||
until the next layout. The figure seeds its common gutter — the maximum over
|
||||
the rows, which is also what aligns their axes — with that reservation.
|
||||
The reservation is the widest label `format_si()` writes for an axis with no
|
||||
unit (a sign, three significant figures and a one-glyph prefix), not the
|
||||
wider `%.3g` fallback it used to cover: that form needs a value past 1e24, so
|
||||
it cost a third of the gutter for a label no real signal produces — and since
|
||||
the floor is a monotone maximum over the *measured* need, one that did appear
|
||||
would widen the gutter for good anyway.
|
||||
2. *The status line drove the pane width.* A `GtkLabel`'s minimum width is its
|
||||
text, and the status line is rewritten every frame; as its counters and
|
||||
messages changed width they moved the paned divider, which resized the plot,
|
||||
which re-laid out the axes and their ticks. The label is now ellipsized, so its
|
||||
minimum is “…”; the plot keeps the width it was given.
|
||||
3. *The gutter was a ratchet.* `note_y_gutter()` is handed the gutter the row was
|
||||
**laid out with** — already the maximum of its labels and the floor imposed on
|
||||
the frame before — and it added six pixels of slack to it. Those six pixels came
|
||||
back next frame and were added again, so the left margin climbed a decade of
|
||||
pixels a second until it hit the 200 px cap and stopped: 200 px of an 870 px
|
||||
viewport, with `axes.w changes: 17` beside it, which reads as a settle and is a
|
||||
runaway that ran out of room. The reservation's own padding is the slack, once;
|
||||
nothing is added to a measurement that is partly our own answer.
|
||||
|
||||
The self-check reports both. `viewport.w` must collapse to a single value, and
|
||||
**The two sides are balanced.** The axes rect used to reach the viewport's right
|
||||
edge, because that is all the library's layout had ever reserved — so the
|
||||
rightmost x tick label, *centred* on its tick, had half of itself outside the plot
|
||||
(a live axis is `[-window, 0]`, so there is always a tick exactly on that edge) and
|
||||
the newest sample's ink landed on the frame. `Plot::set_right_gutter()` and
|
||||
`Figure::set_right_gutter()` were added upstream for this: an empty band, served
|
||||
from the same half-viewport allowance as the label and title strips, set once on
|
||||
every row because the rows' axes have to end on a common column as well as start on
|
||||
one. `RowSet` asks for half the widest time label plus the label padding, which is
|
||||
exactly what the last label needs to be drawn whole. With the two fixes above the
|
||||
figure now sits in a 104 px left gutter holding the y labels and a 59 px empty band
|
||||
on the right, where it was 200 px and nothing.
|
||||
|
||||
The self-check reports all of it. `viewport.w` must collapse to a single value, and
|
||||
`axes.x`, `axes.w` and `y-gutter` must settle: the one-shot label reservation
|
||||
lands a few frames after the first measured frame, and because the figure serves
|
||||
its floor out of the label strip the reservation converges over two layouts, so
|
||||
@@ -863,9 +1296,9 @@ bug. `x-ticks` is content, and varies by one as a live window slides over a roun
|
||||
number:
|
||||
|
||||
```
|
||||
layout over 952 frames: axes.x 67..80, axes.w 546..559, viewport.w 627..627,
|
||||
y-gutter 44..74, x-ticks 20..21, x-desync 0
|
||||
axes.w changes: 2 (last at frame 35, side panel up=1)
|
||||
layout over 583 frames: axes.x 88..104, axes.w 707..782, viewport.w 870..870,
|
||||
y-gutter 88..104, x-ticks 20..41, x-desync 0
|
||||
axes.w changes: 1 (last at frame 1, side panel up=1)
|
||||
```
|
||||
|
||||
Set `UDPSCOPE_PROBE_PAUSE_MS=<ms>` to pause the acquisition part-way through a
|
||||
@@ -931,6 +1364,16 @@ A third, found the same way, is in the tick generator rather than the shaders:
|
||||
— where the same residue read `3.19e-13ms`. A range whose ticks are *legitimately*
|
||||
that small keeps them.
|
||||
|
||||
4. *One second was labelled `1e+3ms`.* `format_time()` picked its rung by comparing the raw
|
||||
value against each unit's scale and then wrote the mantissa with `%.3g` — so a tick a hair
|
||||
under a rung's top (a 1 s tick computed as 0.9999999 s, which is what differencing a live
|
||||
axis gives) stayed on the rung below and its mantissa *rounded up to 1000*, which `%.3g`
|
||||
writes in exponential form. The fix is to pick the rung by the **rounded** mantissa
|
||||
(`t < u.scale * 0.9995`), which is exactly the `>= 999.5` spill guard `format_si()` in the
|
||||
same file already had; the whole-second branch takes the same slack. The test is
|
||||
`format_time_never_lets_a_field_reach_its_radix`, extended with the boundary of every rung
|
||||
from seconds down to nanoseconds and its negative.
|
||||
|
||||
**The minor grid is texture, the labelled grid is structure.** The subdivisions used to be
|
||||
drawn exactly like the labelled lines — same width, same opacity — so a plot dense enough to
|
||||
need them read as two competing sets of ticks. They are now three things the style states
|
||||
@@ -957,6 +1400,7 @@ so it is one path, and it costs what the crosshair's own lookup costs.
|
||||
|
||||
Each carries a test upstream (`the_atlas_is_filtered_not_point_sampled`,
|
||||
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero`,
|
||||
`format_time_never_lets_a_field_reach_its_radix`,
|
||||
`a_sparse_window_between_samples_still_inks_both_margins`), and each
|
||||
was verified to fail with its own fix reverted.
|
||||
|
||||
@@ -984,10 +1428,64 @@ is what made a dragged cursor spring to where it had been. A ruler is per row: e
|
||||
two different values, and the pane names the row.
|
||||
|
||||
`mix` needs no rulers: it reports the value the trace actually has at each marker, read
|
||||
through the library's `Plot::crosshair_value()` — the two bracketing samples,
|
||||
interpolated — so it is the answer to "what is this signal at these two times" rather
|
||||
than "drag a ruler until it crosses the trace". A readout is `--` when the trace has
|
||||
nothing at that time and `partial` when only one of the two markers is over samples.
|
||||
through the library's `Plot::crosshair_value()` — **the nearer of the two bracketing
|
||||
samples**, never a number between them — so it is the answer to "which datum of this
|
||||
signal is at these two times" rather than "drag a ruler until it crosses the trace". A
|
||||
readout is `--` when the trace has nothing at that time and `partial` when only one of
|
||||
the two markers is over samples.
|
||||
|
||||
The value used to be a linear interpolation between the brackets, which is upstream's
|
||||
old convention and was wrong for this app in two ways at once. The samples here are
|
||||
**already averaged** — ingest decimation is a boxcar over `time_stride` samples (see
|
||||
"Decimation at ingest is an average") — so a number computed *between* two of them is a
|
||||
second, undeclared estimate laid over the first, and nothing on screen or in an export
|
||||
holds it. And at a zoom past the sample spacing it is visibly not the datum the pointer
|
||||
is over: the polyline between two samples is a straight line the renderer drew, and
|
||||
quoting a point on it reports the drawing rather than the data. A tie reads the earlier
|
||||
sample. The fix is upstream (`deps/plotter`, `crosshair_span()`'s `t` is a selector rather
|
||||
than a blend factor), so the library's own hover crosshair changed with the pane — which is
|
||||
the point, since both are the same lookup and a user comparing them must not see two
|
||||
numbers. Upstream keeps the blend as `CrosshairValue::Interpolated` for hosts whose x is a
|
||||
quantity they are willing to interpolate; the nearest sample is the default and udpscope
|
||||
never states the other.
|
||||
|
||||
**The crossing dot is on the sample, not on the marker's column.** The dot is placed
|
||||
through `Plot::crosshair_point()` — the same lookup as the value, returning the quoted
|
||||
sample's own x as well as its y — so the mark the eye follows and the number in the pane
|
||||
are the same datum. The two x's differ by up to half a sample period: nothing at a
|
||||
whole-window view, and the whole width of the panel once a few samples span the axes, which
|
||||
is exactly the state in which a reader is asking "which point is that?". It therefore leaves
|
||||
the dot a little off its own vertical line by design — the line says where the *marker* is
|
||||
and the dot says which *datum* is being quoted, and when the marker is between two samples
|
||||
those are two places. The library's hover crosshair snaps the same way and draws its mark
|
||||
`Style::crosshair_marker_px` across (9 logical px against a trace's own 3 px sample dots),
|
||||
since a snapped mark lands among those dots and at their size would read as one more sample
|
||||
instead of as the answer to the question the hover asks.
|
||||
|
||||
**A zoomed-in trace is drawn on the x its readout quotes, which needed the gather's origin to
|
||||
become the caller's.** Separately from the half-sample offset above — which is a property of
|
||||
*which datum* is quoted — there was an offset in *where the polyline was drawn*, and it grew
|
||||
with the zoom. `plot::data::Series::gather()` stages x as `f32`, which cannot hold an absolute
|
||||
epoch time, so it emits a value relative to an origin the consumer adds back in `f64`. That
|
||||
origin was fixed at `x0()`, the start of the **retained window**: the f32 then had to resolve
|
||||
2^-24 of the whole ring however little of it was on screen — 1.9 µs over 20 s of retained data
|
||||
— while a readout reads `x_at()`, exact `f64`. At a view 10 µs wide across a 716 px axis
|
||||
(14 ns a pixel) that is over a hundred pixels between the drawn sample and the x reported for
|
||||
it, and it halves with every zoom *out*, which is why it read as "zooming in makes the
|
||||
crosshair pick the wrong point".
|
||||
|
||||
The origin is now an argument, and `Renderer::draw_traces` passes the **visible range's
|
||||
minimum** — so the quantisation is 2^-24 of the span on screen, a fixed 4e-5 of a pixel, at
|
||||
every zoom. The fix is upstream (`deps/plotter`), where the parameter was deliberately placed
|
||||
*before* the output buffer so that every one of the library's and this app's call sites was a
|
||||
compile error rather than a silent change of behaviour. One simplification fell out of it:
|
||||
`stage_xy()` took a gather origin and a draw origin, those are now always the same value, and
|
||||
the linear case hands the gathered buffer straight to the line batch with no per-point copy.
|
||||
Two guards: the library's
|
||||
`a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes`, which measures the
|
||||
error in pixels both ways (under 0.05 px with the view's minimum, over 10 px with `x0()`), and
|
||||
this app's `trace ink:` line, which now reports `worst drawn-vs-quoted P px` over the raw-level
|
||||
paints of a whole run.
|
||||
|
||||
The readouts are a **table**: a header naming the columns, then one row per instrument —
|
||||
`| who | A | B | delta |`. The first row is the two times and their difference, and the
|
||||
@@ -1001,7 +1499,16 @@ Every readout is a **measurement**, not a raw number: at most two decimals, scal
|
||||
SI prefix so the digits sit in `1..999`, and the unit the stream declared appended —
|
||||
`10.52 mV`, `-9.8 V`, `dT -398.67 ms`, `1.06 pV`. Two decimals is the resolution the
|
||||
instruments can claim (a marker lands on a sample, and the sample is an `f32`), and a
|
||||
trailing zero is dropped, so `1.50 V` reads `1.5 V`. A row's rulers use its signals' unit
|
||||
trailing zero is dropped, so `1.50 V` reads `1.5 V`. The prefix is applied **whether or not
|
||||
the stream declared a unit**, and that is not a detail: a signal whose CONFIG declares none is
|
||||
ordinary, a *math* channel never has one, and two decimals of an unprefixed small value is
|
||||
`0.00` — the reading was simply gone for an entire class of channels, which went unnoticed
|
||||
because the mock declares `V` for everything (it now takes `--unit ''`, which is the only way
|
||||
a probe can see this). With no unit the prefix is written *tight* against the number —
|
||||
`585.24m`, not `585.24 m` — because the space is there to separate the number from its unit,
|
||||
and a spaced `m` with nothing after it reads as metres. Below `1e-12`, where no prefix can
|
||||
help, the value falls back to three significant digits in whatever form fits rather than to
|
||||
`0.00`. A row's rulers use its signals' unit
|
||||
when they agree on one and print bare digits when they do not: a ruler is on the row's y
|
||||
axis, and two units on one axis make "the" unit a guess. A trace's line carries the
|
||||
signal's **colour chip and its name** — `● sine 616.49 mV -24.57 mV -641.06 mV` — and the
|
||||
@@ -1092,10 +1599,24 @@ logged with both rates and the new anti-alias corner. A window change that leave
|
||||
limitations below); only the shared clock is decimated.
|
||||
|
||||
**Fit to data** (overflow menu, the `f` key, or **Fit all** in a plot's right-click menu)
|
||||
auto-scales every row's y axis and re-frames the x window on the data the ring holds,
|
||||
propagated to every row. **Fit X axis** is the same re-framing without the y scaling, for
|
||||
the common case where the window should be the data's but the levels were placed by hand.
|
||||
The status line acknowledges either (`x fitted to the buffered data (1.25 s)`).
|
||||
auto-scales every row's y axis to the data it holds and puts the x view back on the whole time
|
||||
window, propagated to every row. **Fit X axis** is that x half alone, for the common case where
|
||||
a zoom should be undone but the levels were placed by hand. The status line acknowledges either
|
||||
(`x back to the 2 s window`).
|
||||
|
||||
**Nothing you do on the plot changes the time window** — only the window control does. A fit is
|
||||
the *undo of a zoom*: a 2 s window zoomed into reads 2 s again. That matters because the window
|
||||
is not just the axis. It is also the trigger's event span, how the samples are stored (a window
|
||||
long enough to move the decimation re-plans the rings and **drops the history**), the control's
|
||||
readout and what the session file records — so a wheel notch or a Fit that redefined it would
|
||||
quietly throw the buffer away. A zoom and a pan are view-only by construction; the fits read the
|
||||
window and never write it.
|
||||
|
||||
Which 2 s depends on the state, and the fit restores the whole of whatever the view is allowed to
|
||||
show: live, the newest window at the live edge; and for a held event or a paused view, the pin's
|
||||
full range — the snapshot's own span, which is legitimately *narrower* than the requested window
|
||||
when the ring had not filled it. A fit of a frozen view keeps it frozen rather than throwing it
|
||||
back onto the stream.
|
||||
|
||||
**Shared x axis.** The rows are the rows of **one `plot::Figure`**, drawn by one
|
||||
`plot::gtk4::PlotArea` in figure mode, and they all join a single x link group.
|
||||
@@ -1105,7 +1626,7 @@ them all with nothing to propagate by hand. The live follow is the *group's*
|
||||
(`set_x_link_follow`), not each row's — `Figure::link_x()` refuses a member whose
|
||||
own axis still follows, because a following axis would reinterpret the pushed
|
||||
range as its own span and offset behind its own edge, which is per-row drift by
|
||||
construction. The figure also owns the left gutter and the crosshair, so a
|
||||
construction. The figure also owns both gutters and the crosshair, so a
|
||||
vertical feature lands in the same pixel column in every row.
|
||||
|
||||
The self-check's layout line carries `x-desync N`, the number of frames in which
|
||||
@@ -1156,16 +1677,30 @@ division.
|
||||
over the shared x window, at the **stored** sample rate — never the LOD pyramid's
|
||||
buckets, which are the envelope the renderer drew rather than the acquisition. A
|
||||
signal on several rows is one column; the columns come out in channel order, so the
|
||||
file reads in the same order as the signal panel. The table has two time columns:
|
||||
`time` is the axis as displayed (stream seconds live, `[-pre, +post]` while an event
|
||||
is held) and `stream_time` is always the stream's own clock, so two exports taken in
|
||||
different modes line up.
|
||||
file reads in the same order as the signal panel.
|
||||
|
||||
The table has **two time columns**, which is the whole answer to "relative or absolute":
|
||||
|
||||
- **`time`** is the axis exactly as it was displayed — negative ages ending at 0 live, offsets
|
||||
from the edge in trigger mode — so a row's `time` is the x the user read a value at.
|
||||
- **`abs_seconds`** is the stream's own clock for the same sample: `epoch() + stream_at_x0() +
|
||||
time[i]`, one expression correct in all four display modes (see *The x axis is relative to
|
||||
now*). Two exports taken in different modes therefore line up on this column, and
|
||||
`abs - time` is a constant per file naming where the display's origin sat in the stream.
|
||||
|
||||
Both are `f64` seconds and not the wire's integer, because the wire's integer never exists in
|
||||
this process: `udps_frame_t::values` is a `const double*`, so the time signal has already been
|
||||
widened by the client before udpscope sees it. One caveat follows from that and from `time`
|
||||
being a *derived* coordinate: `abs_seconds` is a sum of two numbers of very different
|
||||
magnitudes, so consecutive differences wobble by a few ULP of the stream's uptime
|
||||
(femtoseconds at any plausible uptime — note that the `f32` defect this used to guard against
|
||||
was *microseconds*, four orders of magnitude up).
|
||||
|
||||
Two formats, picked by the extension the save dialog ends up with:
|
||||
|
||||
| file | what it is |
|
||||
|---|---|
|
||||
| `.npy` | one NumPy structured array, `time`/`stream_time` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
|
||||
| `.npy` | one NumPy structured array, `time`/`abs_seconds` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
|
||||
| `.csv` | the same table as text, `%.17g` for the times and `%.9g` for the values: the shortest widths that round-trip each type exactly. |
|
||||
|
||||
Parquet (the original ask) would have meant linking Arrow — tens of megabytes of
|
||||
@@ -1182,6 +1717,71 @@ 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 — `connecting`, `live`, `paused`, `armed`, `capturing`,
|
||||
`event held`, `disarmed`, none wider than ten characters), 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
|
||||
@@ -1191,7 +1791,16 @@ now — they are listed so they are not mistaken for anything else:
|
||||
latched `EventSnapshot` — an immutable copy of the captured window, which is
|
||||
exactly what "the buffer the view is frozen on" needs to be. Two live rings would
|
||||
also mean acquisition had to follow the trigger state, which is what left the first
|
||||
latch after arming with no pre-trigger history.
|
||||
latch after arming with no pre-trigger history. What a second ring would buy is the
|
||||
publish: freezing a whole `BufferStore` copies nothing and inherits the pyramid the
|
||||
producer already maintains, so both figures in the dump's `publish cost:` line would
|
||||
go to zero. They are worth knowing before pricing that — at 1.3 MS/s an event publish
|
||||
measured **69–115 ms of blocked UI tick** and a pause publish **22–86 ms of blocked
|
||||
producer** — but they do not pay for a second budget's worth of memory, a second
|
||||
per-sample write on the producer, and a single frozen slot where the app today holds
|
||||
an event, a pause and an export's window at once. The *observable* cost is also
|
||||
smaller than the stall: an A/B with and without a pause read `gaps=0` both ways, so
|
||||
what a second ring would buy is a smoother frame at a latch, not recovered packets.
|
||||
- **The buffer is allocated at the first CONFIG, not at initialisation.**
|
||||
The spec's "N+1 split" needs N, which only a CONFIG gives; `--buffer-mb` is the
|
||||
*budget*, and the power-of-two ring rounding means the final
|
||||
@@ -1255,6 +1864,17 @@ status line as `N reconnects` once it is nonzero) it says so in the status line
|
||||
leaving an empty plot to be puzzled over, and prints a `starved:` line to stderr while the
|
||||
state holds.
|
||||
|
||||
That notice is **split on whether a CONFIG ever arrived**, because the two states are
|
||||
different diagnoses and only one of them is about another reader. With a signal list in
|
||||
hand, a stream that stops is the fight above: something took the session away, and the
|
||||
notice says so (`starved:` on stderr). With **no CONFIG at all** nothing was ever taken —
|
||||
there is no signal list, so nothing can be enabled and no DATA frame is owed — and the app
|
||||
says `trying to connect` instead (`connecting:` on stderr), with the glance line reading
|
||||
`connecting` and the badge yellow from the first frame rather than after the three-second
|
||||
stall test. Calling that one "connected, but no data is arriving" blamed a second reader
|
||||
that does not exist; the usual cause is a streamer that is not running, or one serving a
|
||||
different port.
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Multi-element (vector/matrix) signals are acquired with a zero-order hold.**
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# SPDX-License-Identifier: MIT
|
||||
#
|
||||
# The `desktop-entry` target's body. Copies the generated entry and the two app icons into
|
||||
# $XDG_DATA_HOME, validates the entry, refreshes the caches that make the shell notice, and
|
||||
# prints every path it wrote.
|
||||
#
|
||||
# This runs only when the target is named. The project deliberately has no install step -- that
|
||||
# is the stated reason its icons are a GResource -- and a target that does nothing until asked
|
||||
# preserves that while still producing a working entry.
|
||||
#
|
||||
# Expects -DENTRY=, -DICON= and -DSYMBOLIC= on the command line.
|
||||
|
||||
if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "")
|
||||
set(data_home "$ENV{XDG_DATA_HOME}")
|
||||
else()
|
||||
set(data_home "$ENV{HOME}/.local/share")
|
||||
endif()
|
||||
|
||||
# Fail visibly if both fallback paths are unusable. The script needs a directory to install into;
|
||||
# a missing $HOME in a sandboxed CI environment should not silently install to the filesystem root.
|
||||
if(NOT DEFINED ENV{XDG_DATA_HOME} OR "$ENV{XDG_DATA_HOME}" STREQUAL "")
|
||||
if(NOT DEFINED ENV{HOME} OR "$ENV{HOME}" STREQUAL "")
|
||||
message(FATAL_ERROR "XDG_DATA_HOME and HOME are both unset or empty; cannot determine install directory")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
set(apps_dir "${data_home}/applications")
|
||||
set(icon_dir "${data_home}/icons/hicolor/scalable/apps")
|
||||
set(sym_dir "${data_home}/icons/hicolor/symbolic/apps")
|
||||
|
||||
# The icons are copied from the source tree rather than extracted from the GResource: the same
|
||||
# two files serve both the in-binary resource and the installed theme, so there is one copy to
|
||||
# edit. `file(COPY)` creates the directories.
|
||||
file(COPY "${ENTRY}" DESTINATION "${apps_dir}")
|
||||
file(COPY "${ICON}" DESTINATION "${icon_dir}")
|
||||
file(COPY "${SYMBOLIC}" DESTINATION "${sym_dir}")
|
||||
|
||||
get_filename_component(entry_name "${ENTRY}" NAME)
|
||||
get_filename_component(icon_name "${ICON}" NAME)
|
||||
get_filename_component(sym_name "${SYMBOLIC}" NAME)
|
||||
|
||||
# Validated here rather than left to the shell, which ignores a malformed entry in silence: the
|
||||
# failure belongs at the moment the entry is asked for.
|
||||
find_program(DESKTOP_FILE_VALIDATE desktop-file-validate)
|
||||
if(DESKTOP_FILE_VALIDATE)
|
||||
execute_process(COMMAND "${DESKTOP_FILE_VALIDATE}" "${apps_dir}/${entry_name}"
|
||||
RESULT_VARIABLE validate_result)
|
||||
if(NOT validate_result EQUAL 0)
|
||||
message(FATAL_ERROR "desktop-file-validate rejected ${apps_dir}/${entry_name}")
|
||||
endif()
|
||||
message(STATUS "desktop-file-validate: ok")
|
||||
else()
|
||||
message(STATUS "desktop-file-validate: not on PATH, entry not validated")
|
||||
endif()
|
||||
|
||||
find_program(UPDATE_DESKTOP_DATABASE update-desktop-database)
|
||||
if(UPDATE_DESKTOP_DATABASE)
|
||||
execute_process(COMMAND "${UPDATE_DESKTOP_DATABASE}" "${apps_dir}")
|
||||
endif()
|
||||
|
||||
find_program(GTK4_UPDATE_ICON_CACHE gtk4-update-icon-cache)
|
||||
if(GTK4_UPDATE_ICON_CACHE)
|
||||
execute_process(COMMAND "${GTK4_UPDATE_ICON_CACHE}" -f -t "${data_home}/icons/hicolor")
|
||||
endif()
|
||||
|
||||
# Printed because there is no uninstall target: undoing this is these three paths.
|
||||
message(STATUS "wrote ${apps_dir}/${entry_name}")
|
||||
message(STATUS "wrote ${icon_dir}/${icon_name}")
|
||||
message(STATUS "wrote ${sym_dir}/${sym_name}")
|
||||
@@ -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
|
||||
")
|
||||
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=udpscope
|
||||
GenericName=Oscilloscope
|
||||
Comment=An oscilloscope for UDPStreamer sources
|
||||
Exec=@UDPSCOPE_EXEC@
|
||||
Icon=org.udpscope.app
|
||||
Terminal=false
|
||||
Categories=Development;Science;Electronics;
|
||||
Keywords=oscilloscope;UDP;signal;waveform;trigger;
|
||||
StartupNotify=true
|
||||
Vendored
+1
-1
Submodule deps/plotter updated: 0ee6e10c81...cc908efa36.
@@ -0,0 +1,201 @@
|
||||
# udpscope — user guide
|
||||
|
||||
A viewer for a UDPStreamer source: it connects, decodes the CONFIG, plots the signals you enable,
|
||||
and lets you freeze, measure and derive from them. This is the *user* guide; `README.md` documents
|
||||
what was built and why, and `SPECS.md` is the requirement document.
|
||||
|
||||

|
||||
|
||||
*The main window with a source streaming: the signal panel on the right, the plot in the middle, the
|
||||
status line directly under it, and the toolbar above. The window control — `2 s` — is the first thing
|
||||
on the left.*
|
||||
|
||||
## 1. Connecting
|
||||
|
||||
```sh
|
||||
udpscope # asks which source to open
|
||||
udpscope --server 10.0.0.5 --port 8000 # connect straight away
|
||||
udpscope --server 239.1.2.3 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
|
||||
```
|
||||
|
||||
With no `--server` the app shows a chooser: the sessions it has stored, each naming its source, plus
|
||||
a form for a new host. A source opened from the list reconnects to the multicast group and interface
|
||||
it was stored with.
|
||||
|
||||
**A unicast source serves one client at a time.** A second viewer's `CONNECT` displaces the first, and
|
||||
they take turns — the status line says so when it happens. Watching one source from several instances
|
||||
needs `--multicast <group>` with `--interface <local address>`.
|
||||
|
||||
Every launch is its own process, so running two scopes side by side is fine.
|
||||
|
||||
## 2. The status line
|
||||
|
||||
Directly under the plot, it reads `● <state> · <rate> · <window>`, and the ⓘ beside it opens the
|
||||
detail: packets, frames, gaps, per-channel sample counts, the buffer's span and its decimation.
|
||||
|
||||
- **The state word is one word** from a fixed set: `live`, `paused`, `armed`, `capturing`,
|
||||
`event held`, `disarmed`. `no data` appears in the rate's place when nothing is arriving.
|
||||
- **The rate is the *stored* rate**, which is the wire rate only while the whole requested window
|
||||
fits in the buffer. A window wider than the budget allows is bought by averaging groups of samples,
|
||||
so the rate falls and the status line says so — that is the anti-alias filter doing its job.
|
||||
- **`connected, but no data is arriving`** appears after three seconds in which no frame arrived while
|
||||
connected: the usual cause is the unicast point above. It clears the moment data returns.
|
||||
- **`N reconnects`** is appended when the client has had to re-establish the session.
|
||||
|
||||
## 3. Signals
|
||||
|
||||
The signal panel lists every signal the CONFIG described, with a switch each. **Signals start
|
||||
disabled — enabling one is what starts acquiring it**, and a signal nobody enables costs nothing.
|
||||
Drag a signal from the panel onto a plot row to add a trace; drag it again to another row to plot the
|
||||
same signal twice. The colour is per *signal*, so the same signal is the same colour wherever it
|
||||
appears.
|
||||
|
||||
The panel also holds the per-signal calibration: gain and offset, applied at ingest, so the
|
||||
measurements and the export carry the same numbers the plot shows.
|
||||
|
||||
## 4. The window and navigation
|
||||
|
||||
The header's window control (`2 s`) sets the time span on screen; the popover takes a number and a
|
||||
unit, with a decade stepper either side. The trigger's event window is a share of *this* span, so a
|
||||
window change moves the trigger with it.
|
||||
|
||||
- **Wheel** zooms about the pointer; **Shift+wheel** on x only, **Ctrl+wheel** y only.
|
||||
- **Ctrl `+` / `-`** zooms x, **Alt `+` / `-`** y, about the plot centre.
|
||||
- **Drag** pans, **right-drag** too (the right button also grabs a cursor at the pointer).
|
||||
- **Ctrl+drag** frames x to a box, **Alt+drag** y.
|
||||
- **Fit to data** (menu) auto-scales both axes and frames the whole buffered range.
|
||||
|
||||
The view is bounded by the **time window**, not by the buffer: zooming out stops at the span you set,
|
||||
and panning stops where the data starts. When the buffer holds *less* than the window — right after a
|
||||
signal is enabled — the window is left empty on its left, which is what a scope that has just started
|
||||
should look like.
|
||||
|
||||
## 5. Trigger
|
||||
|
||||

|
||||
|
||||
*Single mode with a capture held: the axis is the event's own `[-pre, +post]`, the trigger marker
|
||||
sits at the edge, and the status line reads `event held`.*
|
||||
|
||||
The trigger panel has the edge, the pre/post split (as a share of the event span), Normal or Single,
|
||||
and Arm/Disarm. Selecting a signal for the trigger enables it.
|
||||
|
||||
- **While a capture fills** the plot keeps drawing live data on the event's clock, so the post side
|
||||
fills in view; the axis does not move.
|
||||
- **A held event is navigable within its own data** — zooming in works, panning stops at the capture's
|
||||
edges — and it is never quietly replaced by live data.
|
||||
- **Disarming mid-capture** publishes what the capture got and holds that, so the picture on screen
|
||||
when you disarm is the picture you keep.
|
||||
- The event window is counted in **stream seconds** end to end, so a source whose clock advances in
|
||||
jumps still gets the window it asked for.
|
||||
|
||||
## 6. Pause
|
||||
|
||||
Pause freezes the *picture*, not the acquisition: the producer keeps filling the buffer behind it, and
|
||||
the frozen view is a snapshot of the window that was on screen. Resuming shows the data that arrived
|
||||
meanwhile — there is no hole. A paused view is held like a held event, so zooming in works and panning
|
||||
does not walk it off its snapshot.
|
||||
|
||||
## 7. Cursors
|
||||
|
||||
The cursors toolbar toggle switches on two instruments per row:
|
||||
|
||||
- **vertical** — two times, shared by every row;
|
||||
- **horizontal** — two rulers per row;
|
||||
- **both** — the two together;
|
||||
- **mix** — the two times, read off each *trace* where they cross it.
|
||||
|
||||
The pane beside it reports each trace's values at the markers, and **Reset** puts them back at the
|
||||
window's edges. Values read `--` when a marker is past the newest sample and `partial` when only one
|
||||
marker is over data.
|
||||
|
||||
## 8. Measurements
|
||||
|
||||
The measurements pane computes statistics of the **visible window** of a signal: average, top,
|
||||
bottom, min, max, peak to peak, amplitude, pulse duration, period and frequency. `+` adds one, the
|
||||
row's pencil edits it, and **show on plot** draws its result as a ruler (levels) or markers (times).
|
||||
|
||||
`top` and `bottom` take a *split* as a share of the window's own range — the percentage travels
|
||||
between signals where a threshold in volts would not. `period` and `frequency` deliberately have no
|
||||
split: they count rising crossings of the mid level, one edge per cycle, and a split there is a
|
||||
setting you can only get wrong.
|
||||
|
||||
The values are refreshed on a cadence — at most every 100 ms while the window is scrolling, once when
|
||||
it stops, and once per mode change — so a live view cannot cost you frames. The pane's cost is
|
||||
reported in the exit dump with the app's self-check.
|
||||
|
||||
## 9. Math channels
|
||||
|
||||

|
||||
|
||||
*The compact node editor: each node is a box, its inputs the squares on the left edge and its output
|
||||
the square on the right. The orange dot marks the node that carries the channel's value.*
|
||||
|
||||
A math channel is derived from the others and is then an ordinary channel: enable it and it is
|
||||
acquired, plotted, measured and exported like a streamed signal, and another math channel can use it
|
||||
as a source.
|
||||
|
||||
Operations: `channel` (a source), `gain + offset`, `abs`, `sqrt`, `clamp`, `low pass`, `high pass`,
|
||||
`band pass` (biquads, corner in Hz against the stored rate), `sum`, `difference`, `product`, `ratio`.
|
||||
|
||||
- **Click** a node to select it; its parameters appear in the form beside the canvas.
|
||||
- **Click an output square, then an input square**, to wire them. An input is only offered the nodes
|
||||
above it — the graph is a list walked in order, so a cycle cannot be drawn.
|
||||
- **Drag** a box to move it. **add node**, **delete node** and **set as output** are below the form.
|
||||
- The **output is the last node** of a graph you build top to bottom, and the orange dot says which;
|
||||
*set as output* changes it. A graph whose output is the source is valid and just passes the input
|
||||
through — that is the usual reason an operation "does nothing".
|
||||
- **Save** refuses an incomplete graph and says why: an unconnected input, no output, or a source that
|
||||
is not a channel the scope is acquiring.
|
||||
|
||||
The samples are produced at ingest, once per stored slot, so the filters carry real history — a low
|
||||
pass is the filter you asked for rather than a window recomputed every frame.
|
||||
|
||||
## 10. Sessions
|
||||
|
||||
Everything above is saved per source in `~/.config/udpscope/<host>_<port>.toml`: the window, the rows
|
||||
and which signal each slot holds, the calibration, **which signals were enabled**, **the math channels
|
||||
with their graphs** and **the measurements**. Reopening the same source brings the scope back as you
|
||||
left it.
|
||||
|
||||
The file is plain TOML and safe to edit — a malformed line is skipped rather than refusing the whole
|
||||
file. Deleting it starts from the defaults.
|
||||
|
||||
## 11. Export
|
||||
|
||||
**Export visible range…** writes the plotted signals over the visible x range at the *stored* rate to
|
||||
`.npy` or `.csv`, picked by the file name's extension. The table is collected when you click, not when
|
||||
the chooser closes, so a live view cannot scroll under the dialog. The `.npy` is a 2-D array: one row
|
||||
per sample, `time`, `stream_time`, then one column per signal.
|
||||
|
||||
## 12. Keyboard
|
||||
|
||||
| key | action |
|
||||
|---|---|
|
||||
| `space` | pause / resume |
|
||||
| `a` | arm / disarm the trigger |
|
||||
| `t` | the trigger panel |
|
||||
| `l` | the layout editor |
|
||||
| `g` | go live |
|
||||
| `f` | fit to data |
|
||||
| `d` | add the first free signal to the focused row |
|
||||
| `c` | cursors on/off |
|
||||
| `m` | measurements pane |
|
||||
| `e` | export |
|
||||
| `?` | shortcuts |
|
||||
|
||||
`Ctrl+E` exports, `Ctrl+Q` quits, `Ctrl+plus`/`Ctrl+minus` zoom x and `Alt+plus`/`Alt+minus` zoom y.
|
||||
A keystroke is ignored while a text field has focus, so typing in the window control never arms the
|
||||
trigger.
|
||||
|
||||
## 13. Troubleshooting
|
||||
|
||||
| symptom | cause |
|
||||
|---|---|
|
||||
| `connected, but no data is arriving` | another client holds a unicast source — use `--multicast` |
|
||||
| the plot is empty, the status reads `no data` | nothing is enabled: enable a signal in the panel |
|
||||
| the trace is blank while the trigger is armed | `Waiting` — armed and nothing latched yet |
|
||||
| the axis has a margin at the right | a capture is filling: the post side arrives as it is acquired |
|
||||
| a math channel equals its input | its output node is the source — see §9 |
|
||||
| a very wide window shows a low rate | the ingest's anti-alias filter, not a fault |
|
||||
| a level reads `--` | the measurement is invalid in this window (e.g. a period with one edge) |
|
||||
@@ -0,0 +1,118 @@
|
||||
# Architecture: threads, the data path and the layers
|
||||
|
||||
The two threads and what they share, the four display modes, the display clock, and the layers to keep straight.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
`SPECS.md` is the requirement document. `README.md` documents what was actually built,
|
||||
including deliberate deviations from the spec and known limitations — read it before
|
||||
concluding something is a bug.
|
||||
|
||||
### Threads
|
||||
|
||||
Exactly two: a producer thread in `Acquisition` owns `udps_client_t` and calls
|
||||
`udps_client_poll()`; the GTK main thread owns every widget and GL context. They share
|
||||
`ScopeModel` under `ScopeModel::mutex()`, taken once per DATA frame by the producer and
|
||||
once per frame by the UI (`tick()` in `src/ui.cpp`) around `RowSet::bind()`/`acquire()`.
|
||||
Samples themselves cross via `plot::data::RingBuffer` (single producer, single
|
||||
consumer) and never through the lock. Cross-thread scalars are atomics on `ScopeModel`
|
||||
(`trigger_enabled_`, `trigger_armed_`, `trigger_latches_`, `trigger_time_`,
|
||||
`event_`, `generation_`). Two of them are a pair: the producer writes `capture_edge_`
|
||||
before `trigger_pending_` and those two carry release/acquire, so a consumer that sees a
|
||||
capture open sees which edge it belongs to.
|
||||
|
||||
### Data path
|
||||
|
||||
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
|
||||
value ring per channel, sized by `plan_partition()` to cover `--max-window` seconds
|
||||
within the byte budget. The producer always writes it, whatever the trigger is doing —
|
||||
that is what guarantees a full pre-trigger history on the *first* latch after arming.
|
||||
The spec's second buffer (item 1) is the latched `EventSnapshot`, which is already an
|
||||
immutable copy of the captured window; see README for why two live rings were dropped.
|
||||
|
||||
What a trace binds to is `ScopeModel::display_mode()`, one of four states, and
|
||||
`RowSet::bind()` takes it as an argument rather than recomputing it:
|
||||
|
||||
- `Live` — no trigger: the live ring, on the clock of the **newest sample**
|
||||
(`display_epoch() == -newest`), so the axis is a fixed `[-window, 0]` and x reads as an age.
|
||||
- `Capturing` — an edge has been accepted (so the pre window was full) and the post
|
||||
window is still filling: the live ring, on the coming event's own clock
|
||||
(`display_epoch() == -edge`), inside an axis already pinned to `[-pre, +post]`. The
|
||||
post side fills in view and the marker sits at x = 0 throughout.
|
||||
- `Held` — the frozen `EventSnapshot` of the last event, whose stamps are already edge-relative,
|
||||
so the display epoch is 0.
|
||||
- `Waiting` — armed, nothing captured yet: blank, with the live epoch (there is nothing bound,
|
||||
but the axis is still a live one).
|
||||
|
||||
**Disarm during a capture publishes what the capture got to**, then disarms: the producer
|
||||
cuts the window at the newest sample and that partial snapshot becomes the held event, so
|
||||
the picture on screen at the moment of the Disarm stays on screen. `engine.disarm()` clears
|
||||
`pending_`, which drops the display out of `Capturing`, and with nothing published the view
|
||||
fell back to the *previous* event — or to blank on a first capture. No event fired, so
|
||||
`trigger_latches()` is deliberately not bumped; `ScopeModel::event_seq()` counts published
|
||||
snapshots instead, and the frame tick keys its event framing on *that*. Keep the two
|
||||
distinct: the latch count is what the probes assert "how many events fired" with.
|
||||
|
||||
`Capturing` is a **deliberate departure from SPECS:37** ("data visualisation should not
|
||||
be refreshed till next event"), asked for explicitly; that reading is what the old "never
|
||||
the live ring mid-capture" rule enforced. What survives of the rule is that `Held` binds
|
||||
the snapshot *only* — a held event is never quietly replaced by live data — and that the
|
||||
*axis* does not move during a capture: it is the event's window from the edge onwards,
|
||||
never a sliding live view.
|
||||
|
||||
`display_mode()` must be read **once per frame** and passed around (the tick does this,
|
||||
and hands it to `bind()`). The producer can open or close a capture between any two
|
||||
reads, and two reads in one frame put the bind, the epoch, the pin and the status line on
|
||||
different clocks.
|
||||
|
||||
**The display clock is not the stream clock, and `ScopeModel::stream_at_x0()` is the only
|
||||
conversion.** `display_x = stream_t - stream_at_x0()` in **every** mode, and the offset lives in
|
||||
one of two places: a snapshot carries its own (`EventSnapshot::x_offset`, frozen with the
|
||||
picture) and a live view carries it as minus `display_epoch()`. Anything crossing between the
|
||||
two clocks goes through that one accessor rather than picking the branch itself — the export's
|
||||
`abs_seconds` column, the pause handler putting a display window back on the ring to cut it, the
|
||||
measurement marks — because there are four modes and picking the branch by hand gets one of them
|
||||
wrong. It reads atomics only, so it is safe to call inside an existing `mutex()` guard.
|
||||
Three things about the live epoch specifically:
|
||||
- **It is written after `bind()` and `acquire_all()`, not before.** It must be minus the newest
|
||||
sample the *traces* can report, and asking before the acquire is one frame of production
|
||||
stale — at 1 MS/s over a 50 ms window that is a third of the screen to the right of the axis.
|
||||
- **There is no feedback loop**, even though the newest stamp is read through the epoch that is
|
||||
about to change: `RowSet::live_edge()` reports in the epoch currently in force, so
|
||||
`display_epoch() - live_edge()` is minus the *raw* stamp and the fixed point is reached in one
|
||||
frame rather than converged on.
|
||||
- **`Waiting` takes the live epoch too.** Nothing is bound, but the axis is still a live one, and
|
||||
an epoch of 0 there would make the axis jump on the frame a capture closes.
|
||||
|
||||
**A measurement mark is glued to its sample; a cursor stays on screen.** The two instruments move
|
||||
in opposite directions under a rolling axis and that is deliberate — a bench scope's cursors sit
|
||||
where the user put them while the waveform rolls under, and a measurement's mark names the sample
|
||||
it measured. The cursors need no code: their x *is* a display coordinate. The marks are stored in
|
||||
**stream** seconds and reconverted every frame by `place_measure_marks()`, since a mark left in
|
||||
display coordinates would crawl forward at one window per window. `App::measure_window` is
|
||||
stream-clocked for the same reason, and that one is load-bearing: the recompute trigger is "the
|
||||
window moved by 2 % of its span", and in display coordinates a live window is a fixed
|
||||
`[-window, 0]` — it never moves, so the measurements would never be recomputed again.
|
||||
|
||||
Layers to keep straight:
|
||||
|
||||
- `SignalInfo` — one signal as the CONFIG described it. The unit of enable/disable.
|
||||
- `ChannelInfo` / `ChannelBuffer` — the unit of acquisition and plotting. A
|
||||
`ChannelBuffer` is a `plot::data::Series` over a (value, time) ring pair.
|
||||
- `SlotSeries` (`src/channel.hpp`) — a trace's stable series object, rebound each frame
|
||||
to either a `ChannelBuffer` or an `EventSeries`.
|
||||
- `RowSet` (`src/plot_host.hpp`) — **one `plot::Figure`** of up to `kMaxRows` rows (added
|
||||
and removed one at a time by the layout editor), each a
|
||||
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots. The figure owns
|
||||
everything the rows have to agree on: the x window (one x link group every row joins),
|
||||
the live follow (the *group's* — a member's own axis must be `XFollow::Off`, since
|
||||
`Figure::link_x()` refuses a `Live` member), the left gutter and the crosshair.
|
||||
|
||||
- `ExportTable` (`src/export.hpp`) — the visible window as columns, collected from the
|
||||
snapshot or the live rings and handed to the `.npy`/`.csv` writers. Always the stored
|
||||
samples, never the LOD pyramid's buckets.
|
||||
|
||||
`src/ui.cpp` is the GTK4 window (toolbar, trigger panel, signal panel, plot box) plus
|
||||
the `tick()` frame function that binds, updates the trigger state and rewrites the
|
||||
status line. One `plot::gtk4::PlotArea` in figure mode draws the whole figure. It is by
|
||||
far the largest file.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Build and run
|
||||
|
||||
Configuring, building and launching udpscope, the desktop entry target, the command-line options and the two vendored dependencies.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
## Build and run
|
||||
|
||||
```sh
|
||||
git submodule update --init --recursive # deps/plotter is a submodule
|
||||
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||
cmake --build build
|
||||
|
||||
./build/udpscope --server 127.0.0.1 --port 8000
|
||||
./build/udpscope --server 10.0.0.5 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
|
||||
```
|
||||
|
||||
The app can be launched from the shell rather than only from a terminal, but only on request:
|
||||
|
||||
```sh
|
||||
cmake --build build --target desktop-entry
|
||||
```
|
||||
|
||||
That writes `org.udpscope.app.desktop` plus both app icons under `$XDG_DATA_HOME` (default
|
||||
`~/.local/share`), runs `desktop-file-validate` as a **hard failure** (when it is on `PATH`;
|
||||
absent, it says so and installs anyway), refreshes `update-desktop-database` and
|
||||
`gtk4-update-icon-cache` when they are on `PATH`, and prints the three paths it wrote — which is
|
||||
the uninstall instructions, since there is no uninstall target. `cmake --build build` alone never
|
||||
touches `$XDG_DATA_HOME`.
|
||||
|
||||
The entry's `Exec` is the built binary's **absolute path in the build tree**, so **moving or
|
||||
deleting the build directory breaks it.** That is the price of having no install step and it is
|
||||
deliberate: the entry then always launches exactly the binary that was last built. No
|
||||
`StartupWMClass` is needed — GTK takes the WM class from the application ID, and the desktop
|
||||
file's basename is that same string, which is what associates a running window with the entry.
|
||||
|
||||
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
|
||||
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
||||
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards).
|
||||
|
||||
Run with no `--server` and udpscope asks which source to open: the sessions it has stored, plus a
|
||||
form for a new host. Every probe below passes `--server`, so none of them sees it.
|
||||
|
||||
`deps/plotter` is built from source with only the GTK4 adapter enabled
|
||||
(`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 two widgets: the shortcut overview (`AdwShortcutsDialog`) and the About dialog
|
||||
(`AdwAboutDialog`). GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and
|
||||
`AdwShortcutsDialog` is its replacement; `GtkAboutDialog` still exists but
|
||||
`adw_about_window_new()` is deprecated in favour of `AdwAboutDialog`, which shows the
|
||||
application icon as the page's own header. 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`: both `show_about()`
|
||||
and `show_shortcuts()` keep an `AdwDialog*`, cleared on `::closed` (there is no
|
||||
`::close-request`) and closed with `adw_dialog_close()`. They share one `on_dialog_closed()`
|
||||
handler keyed by a pointer-to-pointer (`gpointer slot` cast to `AdwDialog**`), so one
|
||||
handler clears either pointer without knowing which dialog it is. Presenting either 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), `Figure::set_right_gutter()` / `Plot::set_right_gutter()` (an empty
|
||||
band on the *other* side, which the layout had no concept of — `axes` reached the
|
||||
viewport's right edge, so the rightmost x tick label was centred on a line it had half of
|
||||
itself outside of; the guard is `figure_rows_share_one_right_gutter`), and in the GTK4
|
||||
adapter `PlotArea`'s **figure mode** —
|
||||
`set_figure()`, one `input::Controller` per row, nearest-row event routing, a
|
||||
figure-wide crosshair — plus `PlotArea::row_at()` for naming the row a pointer is over.
|
||||
|
||||
The render-quality work went there for the same reason: `kTextFS` now **filters** the
|
||||
glyph atlas (a 2×2 bilinear clamped to the instance's own cell) instead of point-sampling
|
||||
it, and `LineProgram::set_snap()` / `u_snap` rounds an axis-aligned segment's ink onto
|
||||
whole pixels for the chrome that frames a plot (grid and axis lines), never for anything
|
||||
drawn at a data position. `generate_ticks()` also stores the tick at the origin as
|
||||
exactly `0.0` — the walk starts at `ceil(min/stride)*stride` and so never lands on zero
|
||||
when the stride is not a power of two, which labelled it `-139a`. `format_time()` picks its
|
||||
unit rung by the **rounded** mantissa (`t < u.scale * 0.9995`, and the same slack on the
|
||||
whole-second branch) — the `>= 999.5` spill guard `format_si()` in the same file always had —
|
||||
because a tick a hair under a rung's top rounds to 1000 at three significant figures and
|
||||
`%.3g` writes that `1e+03`, which is how a one-second tick read `1e+3ms`. All four carry
|
||||
upstream tests — `the_atlas_is_filtered_not_point_sampled`,
|
||||
`grid_hairlines_land_on_whole_pixels`, `the_tick_at_the_origin_reads_zero` and
|
||||
`format_time_never_lets_a_field_reach_its_radix`. The minor
|
||||
grid's look is set there too, not here: `grid_minor` carries 50% alpha, `grid_minor_width` is
|
||||
0.75 (sub-pixel, expressed as coverage at 1x) and `grid_minor_dash_on`/`_off` are 3/3 — a
|
||||
fragment discard, no extra geometry — so the subdivisions read as texture under the labelled
|
||||
grid instead of as a second set of ticks (`the_minor_grid_is_thinner_dashed_and_dimmer_than_the_major`).
|
||||
README records what they cured.
|
||||
|
||||
Changing any of this means committing in the submodule and moving its pin, and running
|
||||
the library's own suite as well as the udpscope probes (see
|
||||
[verification.md](verification.md)):
|
||||
|
||||
```sh
|
||||
cmake --build deps/plotter/build/debug
|
||||
xvfb-run -a deps/plotter/build/debug/tests/plot_tests # expect "0 failed"
|
||||
```
|
||||
@@ -0,0 +1,112 @@
|
||||
# Invariants: the data path — buffers, decimation and the rings
|
||||
|
||||
What rebuilds a buffer, why decimation is an average, and the two rules the time ring's precision and its first stamp impose.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **Any structural change rebuilds the buffer and drops history**: a CONFIG, an
|
||||
enable/disable, a calibration change, or a **change of display window that moves the
|
||||
decimation factor**. Calibration is applied at ingest, which is why it re-partitions.
|
||||
`ScopeModel::set_target_span()` is the window's path in, and it re-plans *only* when
|
||||
`time_stride` actually changes, so walking the window inside one decade is free; it asks
|
||||
`plan_partition()` rather than recomputing the factor, because two expressions for that
|
||||
decision is how the buffer and the view end up disagreeing about the stored rate. It
|
||||
takes `mutex()` itself — do not call it holding the lock.
|
||||
- **The shared clock is decimated to fit the requested window; channels never are.** A
|
||||
window too long to store at the wire rate used to be refused (the control was capped to
|
||||
`Partition::span_s`), which made a 600 s view of a 1 MS/s stream unreachable.
|
||||
`plan_partition()` now picks `time_stride`, a power of two, **after** the growth loop has
|
||||
bought all the ring the budget allows — deciding it earlier over-decimates by whatever
|
||||
factor growth was about to add. Per-channel `stride[i]` stays 1: a stride of
|
||||
`round(dt_i / dt_fastest)` keeps one sample per stride, and when that equals the signal's
|
||||
own period — a 2 kHz sine on a 1 MHz clock, stride 500 — every kept sample sits at the
|
||||
same phase and the channel plots flat.
|
||||
- **Decimation at ingest is an average, never a stride.** `Acquisition::on_data()` reads
|
||||
and accumulates *every* sample and pushes the mean of each group of `time_stride`
|
||||
(`value_acc_`/`value_acc_n_`, and `time_acc_` for the clock). That boxcar's first null is
|
||||
at the stored rate, so content that would fold back is attenuated instead of reappearing
|
||||
as a false low frequency — which is the whole reason decimation was once dropped
|
||||
entirely. Skipping the read for unstored samples re-introduces the aliasing; the
|
||||
`UDPSCOPE_PROBE_WINDOW_MS` run under `--freq 2000` is the guard (the anti-alias check in
|
||||
[probe-readings.md](probe-readings.md)).
|
||||
The stamp pushed is the group's **mean**, not its last member: that is where the boxcar's
|
||||
group delay puts the value, and stamping at the end of the group shifts every trace half
|
||||
a stored sample late. The accumulators are reset whenever `time_stride` or the channel
|
||||
count changes, so a re-plan starts a fresh group rather than finishing one at the wrong
|
||||
length. The LOD min/max pyramid still preserves the extremes *of what is stored*; a spike
|
||||
narrower than a group is now filtered away rather than aliased, which is the correct
|
||||
trade and is why the status line's `rate` field is worth reading.
|
||||
- **The trigger sees the stored (filtered) signal, not the wire signal.** `engine.process()`
|
||||
is fed the averaged value, inside the `if` that pushes it. An edge found on a sample that
|
||||
is not in the buffer would be cut from data that does not contain it.
|
||||
- **The time ring is `f64` and the value rings are `f32`, and the asymmetry is the point.**
|
||||
A value's precision is a property of the signal (24 bits is plenty for a sample); a
|
||||
timestamp's is a property of *how long the scope has been running*, because the stamps are
|
||||
stored relative to an epoch fixed at the first sample ever seen and so grow without bound
|
||||
while the interval they must resolve stays one sample period. `f32`'s ULP is 1.9 µs at 16 s
|
||||
of uptime, 3.8 µs at 32 s, 7.6 µs at 64 s and 61 µs over a 600 s window — so at 1 MS/s two
|
||||
to four consecutive samples shared a stamp after half a minute, eight after a minute, and
|
||||
the LOD envelope over such a plateau drew a zero-width segment, i.e. a vertical spike where
|
||||
the waveform should be. That is "zoom in to ~10 µs and three to five points share a
|
||||
timestamp, and min/max produces artefacts". **Do not widen the values to match**: they are
|
||||
the memory budget, and nothing about them is magnitude-bound this way. Three consequences:
|
||||
`plan_partition()` can no longer count slots in its growth loop (a time slot and a value
|
||||
sample are different sizes, so it weighs the budget in **bytes** — `sizeof(f64)` for the
|
||||
clock plus `sizeof(f32) / stride[i]` per channel — and `bytes_for()` is also what bounds
|
||||
the doubling against overflow); `live_window_locked()` compares its bounds in the ring's own
|
||||
type with **no rounding step**, the old `f32` round-trip having existed only so that a
|
||||
sample sitting exactly on a bound was kept whatever the stream's absolute time was; and
|
||||
`EventSnapshot::stamps` is `f64` because it is cut from this ring and is therefore in the same
|
||||
units, whatever a given snapshot's own origin is (both kinds are rebased now — an event's
|
||||
stamps are edge-relative, a pause's freeze-relative — so neither is magnitude-bound the way the
|
||||
ring is, and the width is for the cut rather than for the coordinate). `RingBuffer<T>` stores
|
||||
slots as `std::atomic<T>` behind a `static_assert(is_always_lock_free)`, which `double`
|
||||
satisfies on every supported target. The guard is the **export**: over a multi-million-row
|
||||
`.npy`, `np.diff(t)` must hold one step to within a few ULP of the column's magnitude (see
|
||||
the export guard in [probe-readings.md](probe-readings.md) for why it is no longer exactly one
|
||||
value). Three steps at 2, 2.5 and
|
||||
3 ULP of *the stream's uptime* — microseconds, not femtoseconds — is this defect.
|
||||
- **A dropped time slot is clamped, never answered with a sentinel.** `acquire()` pins
|
||||
the published window to the value ring's base and both rings keep sliding afterwards —
|
||||
their capacities are equal, so there is no slack — which means the oldest few samples
|
||||
of a saturated window routinely point at time slots the producer has already
|
||||
overwritten. `ChannelBuffer::time_of()` therefore clamps to the oldest *readable*
|
||||
slot: a stamp of `0` would reach `x_bounds()`, and from there `dx()` and `x0()`, which
|
||||
is the renderer's whole index mapping — one unreadable slot inflated `dx()` by the
|
||||
ratio of elapsed stream time to the retained window and clipped the older part of
|
||||
every live view. The library's `StreamSeries` is immune for a reason worth copying:
|
||||
its x is `x0 + i*dx`, a function of the absolute index, so an overwritten slot costs a
|
||||
*y* value, never the geometry. Clamping buys the same bargain here.
|
||||
- **The window's *first* stamp is cached by `acquire()` and never read at paint time.** The
|
||||
clamp above makes an unreadable slot *survivable*; it does not make reading it *safe*, and
|
||||
`absolute(0)` is not an ordinary slot — `acquire()` pins the window to `values_->base()`, so
|
||||
sample 0 is the ring's oldest, the very slot the producer is about to overwrite. `time_of()`
|
||||
checks the bounds and then copies, and when the producer laps the slot in between the stamp it
|
||||
returns is **one whole ring-span too new**. Everything else on a `ChannelBuffer` is read at
|
||||
paint time with no lock, and `x0()` *was* the origin the renderer reconstructed every trace
|
||||
point from (`gather[i] + series.x0()`), read both inside its increasing-suffix filter and again
|
||||
at `stage_xy()` — so one torn read of that one slot produced three pictures, all of them "the
|
||||
waveform is gone while the grid and the axes stay": `neighbours()` tests against `x_at(0)` and
|
||||
refused the whole visible range (the renderer then gathered the *entire* ring at a coarse level
|
||||
and drew it off the axis — `idx=[0..8388608) level=7 cover=320%`); `x0()` changing *during* the
|
||||
filter loop dropped three quarters of the points; and `stage_xy()` shifted the finished polyline
|
||||
a ring-span off its axis. The gather origin is the *view's* minimum now (see the `gather()`
|
||||
invariant), a local of the renderer's, so the second and third of those are structurally
|
||||
impossible — but **the first is not**, `x_at(0)` is still what `neighbours()` tests the visible
|
||||
range against, and that is the one that gathered the whole ring. The cache is load-bearing for
|
||||
that reason; do not read the slot at paint time because two of its three symptoms are gone.
|
||||
That is the "with measurements and cursors active the plot flickers as
|
||||
if there were no data, at no constant frequency" defect. It is **live only** (a snapshot is
|
||||
immutable, so `x0()` is constant for `Held`/`Paused`), it is a race against packet arrival so it
|
||||
is irregular, and the measurements pane makes it both likelier and larger: the pane holds
|
||||
`mutex()` for each `MeasureWindowReader`'s life, so the released producer bursts several packets
|
||||
at once. `acquire()` runs on the consumer under `ScopeModel::mutex()` — the same lock the
|
||||
producer's pushes hold — and the window is pinned on the line above, so that is the one point in
|
||||
the object's life where the slot is safe to read; `window_x0_` is also the *right* answer rather
|
||||
than merely a stable one (it is the stamp sample `window_base` actually carries, where a
|
||||
paint-time clamp answered with whatever slot happened to be oldest *now*). `x_at(0)` is served
|
||||
from it too, for the same reason, and `invalidate()` clears it with the window. The guard is
|
||||
`trace ink:` / `[ink]` (see [probe-readings.md](probe-readings.md)). The same torn read still exists for a small `i > 0`
|
||||
— the oldest frame or so of samples — but there its observable is a *prefix plateau*, which is
|
||||
exactly what the renderer's increasing-suffix filter was written to absorb, so it is a different
|
||||
symptom and deliberately not fixed here.
|
||||
@@ -0,0 +1,161 @@
|
||||
# Invariants: GTK traps, the status bar, the icons and the help
|
||||
|
||||
The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and the help pages.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The status line's per-second fields are measured once a second, not per frame.**
|
||||
`App::stored_rate`/`declared_rate` and the label's tooltip are refreshed on the same
|
||||
tick as the channel dump; the status line only formats what is already there. The
|
||||
badge's markup is likewise rewritten on a change of phase (twice a second), never on
|
||||
all sixty frames.
|
||||
- **A `notify::<prop>` handler takes three parameters, a `value-changed` handler two.**
|
||||
`GObject::notify` passes `(GObject*, GParamSpec*, gpointer)` while
|
||||
`GtkSpinButton::value-changed` passes `(GtkSpinButton*, gpointer)`, so one callback cannot
|
||||
serve both: on the `notify::` connection the `GParamSpec*` lands in the `user_data` slot
|
||||
and the handler dereferences it as its `App*`. `G_CALLBACK` is a cast, so nothing warns.
|
||||
That is why the trigger panel's spin buttons and its dropdowns have separate thin entry
|
||||
points (`on_trigger_field_changed`, `on_trigger_drop_changed`) onto one
|
||||
`publish_trigger_fields()`: changing the edge or Normal/Single dropdown used to segfault
|
||||
outright. `UDPSCOPE_PROBE_FIELDS_MS` is the guard.
|
||||
- **`GtkDropDown`'s `notify::selected` arrives after the `set_selected()` that caused it
|
||||
returns**, so the `updating_panels` flag — set around a programmatic write and cleared when
|
||||
it returns — does *not* suppress it. Where the write is part of a readout the handler reads
|
||||
back, that is a feedback loop: `sync_window_display()` writes a value rounded for reading
|
||||
into the window entry and picks the unit for it, and the late notify had
|
||||
`on_window_unit_changed()` re-parse that rounded text, turning a 500 s request into
|
||||
499.98 s. The guard is `App::window_sync_unit` — the unit we last synced — and a selection
|
||||
equal to it is ours, since GTK only notifies on a change. Any new control that both writes
|
||||
and reads a `GtkDropDown` needs the same shape; the flag alone is not enough.
|
||||
- **The header bar's buttons are icon-only**, so headless probes must reach them
|
||||
through the `App` pointers (`trigger_toggle`, `pause_button`, …), not via
|
||||
`find_labelled_button()`. The **pane switches are toolbar buttons** too (`cursors_toggle`,
|
||||
`measures_toggle`, `math_toggle` -- packed with `header_bar_pack_end()`, where the **first** packed
|
||||
is the rightmost, and the bar is an *ancestor* rather than the parent because packing parents into
|
||||
an internal box), and the cursors pane owns its own
|
||||
**Reset** button rather than the overflow menu owning one for it. The dump prints
|
||||
`panel controls: … ; toolbar, left to right: …` to say where they are and in what order. The widget
|
||||
tree's child order *is* the visual order left to right, so that line reads as the *reverse* of the
|
||||
pack order -- it is how an arrangement gets checked with no screenshot, and it is what finally
|
||||
settled the pack-order rule above (two comments in `build_toolbar()` disagreed with each other and
|
||||
one of them with reality): it is computed on the first *tick*, because the dump
|
||||
runs after the window is destroyed and asking GTK about a widget then is four criticals, which is
|
||||
how the first version of that line earned them. What stays in the menu is what acts on the *data*
|
||||
(Fit, Export) and the two editors that are not panes (Layout, Signal list). The Arm/Disarm button in the trigger panel still has text.
|
||||
- **Six of this app's icons are its own, and they are a `GResource` compiled into the
|
||||
binary.** No icon theme has a glyph for an oscilloscope trigger, a pair of cursors or a
|
||||
measurement ruler, and the stock names those buttons used to carry said something else
|
||||
entirely: a *record dot* for the trigger and three different **magnifiers** for cursors,
|
||||
measurements and math. `src/icons/scalable/actions/udpscope-{trigger,cursors,measure,math}-symbolic.svg`
|
||||
are the four action icons; beside them in `src/icons/scalable/apps/` are the two
|
||||
**application** icons, `org.udpscope.app.svg` (128px colour artwork) and
|
||||
`org.udpscope.app-symbolic.svg` (16px). The `apps` context is where an icon theme keeps an
|
||||
application's own icon and is where the **in-process** window icon resolves from; it is also
|
||||
the layout that `cmake/install_desktop_entry.cmake` mirrors when it copies the same two files
|
||||
into `$XDG_DATA_HOME/icons/hicolor/scalable/apps` and `.../symbolic/apps` — those installed
|
||||
copies are what makes `Icon=org.udpscope.app` in the desktop entry resolve (a desktop shell
|
||||
cannot read a GResource compiled into the binary).
|
||||
All six are bundled by `src/icons/udpscope.gresource.xml` (CMake runs
|
||||
`glib-compile-resources`) and reached through **one**
|
||||
`gtk_icon_theme_add_resource_path()` in `run_ui()`, right after `adw_init()` — not at the top
|
||||
of `build_toolbar()`, where it used to be: there are two consumers now (the window's own icon,
|
||||
set before the toolbar is built, and the toolbar's buttons) and `run_ui()` is the one place
|
||||
ahead of both. `adw_init()` calls `gtk_init()`, which is what makes
|
||||
`gdk_display_get_default()` valid there.
|
||||
A resource rather than files beside the binary because udpscope is run straight out of the
|
||||
build tree and has no install step. Five authoring rules, each of which fails quietly:
|
||||
* **GTK's symbolic recolour only reaches `fill` on `path`, `rect` and `circle`.** It wraps the
|
||||
SVG in a stylesheet for those selectors, so a `stroke`, a `line` or a `polyline` keeps the
|
||||
colour written in the file and stays dark on a dark theme. **In a symbolic icon everything is
|
||||
therefore a filled `path`** — including anything that wants to look stroked, which is drawn as
|
||||
a closed band (two offset curves joined) instead. The rule is scoped to symbolic icons on
|
||||
purpose: `org.udpscope.app.svg` is 128px colour artwork that is never recoloured, so it strokes
|
||||
its sine freely, which is the sane way to draw a smooth one. Do not "fix" one to match the
|
||||
other.
|
||||
* **Whole-pixel coordinates**, because these are drawn at 16 px far more often than not: the
|
||||
first draft placed features on half-pixels and the whole icon was a grey smear at 1x.
|
||||
* `fill-rule="evenodd"` is how the ruler's body is a hole *and* its ticks still fill -- a tick
|
||||
inside the hole is three crossings deep, which is odd. Nonzero would fill the hole back in.
|
||||
* Overlapping subpaths under the default nonzero rule must be **wound the same way** or they
|
||||
cancel instead of uniting. All of them run clockwise.
|
||||
* **A malformed SVG is a build error, and that is the only place it can be caught.** Every
|
||||
`<file>` in `src/icons/udpscope.gresource.xml` carries `preprocess="xml-stripblanks"`, which
|
||||
makes `glib-compile-resources` pipe it through `xmllint` — so `--` inside an XML comment, which
|
||||
is illegal and which librsvg refuses the whole file for, fails the build with a file, a line
|
||||
and a caret under the offending character (`…app-symbolic.svg:5: parser error : Double hyphen
|
||||
within comment`). It has to be here because **no runtime check can see it**: librsvg refuses
|
||||
the file but the icon *name* still resolves, so `gtk_icon_theme_has_icon()` returns true and
|
||||
the icon draws nothing. `gtk_icon_theme_lookup_icon()` cannot tell either (see the `app icons:` line under
|
||||
Verification), `GMarkup` accepts the double hyphen because it is a lenient subset parser, and
|
||||
`gdk_pixbuf` goes through glycin here, which sandboxes a subprocess per load and refuses this
|
||||
project's own icons outright. The cost is that the `xmllint` binary is a build dependency; it
|
||||
ships with libxml2, which GTK already requires.
|
||||
`gtk_icon_theme_has_icon()` is checked for every toolbar button and reported by the dump (see
|
||||
`toolbar icons:` in [probe-readings.md](probe-readings.md)): 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 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** (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`,
|
||||
`disarmed`) for the layout reason above; a word **wider than the widest already there**, or a
|
||||
field whose width tracks the stream, breaks the row the plot is laid out under. The set may
|
||||
grow — `connecting` was added for the no-CONFIG state — but only on that condition, and the
|
||||
check is the dump's `max_glance_chars`. `connecting` is derived from `have_config` alone and
|
||||
sits **ahead of every other branch**, including the display mode's: with no signal list, every
|
||||
other word claims something that does not exist yet. The `mode` prose branches the same way for
|
||||
the same reason, which is what keeps the two tiers from disagreeing.
|
||||
- **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.** There is
|
||||
exactly **one `GtkWindow`** among the three — the paged help window. It closes 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 other two — the About dialog (an `AdwAboutDialog`)
|
||||
and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**:
|
||||
they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request`
|
||||
on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the
|
||||
pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for
|
||||
two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own 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`.
|
||||
A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources).
|
||||
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
|
||||
at all inside a box, so without the request it is allocated nothing and the page renders its text
|
||||
with a silent gap where its screenshots should be — which is what the first version did.
|
||||
- **`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.
|
||||
@@ -0,0 +1,235 @@
|
||||
# Invariants: the math channels and the node editor
|
||||
|
||||
A derived channel is an ordinary channel; the engine runs on the producer thread, so its cost is measured in packets.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **A math channel *is* a channel.** `ScopeModel::set_math_channels()` appends one *synthetic*
|
||||
`SignalInfo` (with `math = true`) per math channel after the stream's own, so the panel's switch,
|
||||
the plot binding, the measurements and the export all handle a derived channel without knowing it
|
||||
is derived -- and another math channel is a valid `Source` for exactly the same reason. The
|
||||
*samples* come from the producer: `Acquisition::on_data()` collects each channel's value for the
|
||||
slot and calls `ScopeModel::push_math_sample()` **once per stored slot**, before the deferred
|
||||
latch (`publish_event()` clamps its snapshot to every ring's `produced()`, so a derived sample
|
||||
pushed later would be missing from the capture's newest slot). The math therefore runs on the
|
||||
*stored* (filtered, decimated) samples, which is also why `math_prepare()` gets `slot_dt() *
|
||||
time_stride`: a cutoff the user typed in Hz means the rate the filter will actually run at.
|
||||
`src/math_channel.cpp` is the engine: a graph evaluated as a **list walked in order**, so a node
|
||||
may only read ones *before* it and a cycle is impossible by construction rather than by a check
|
||||
(`math_channel_valid()` states that as a message the dialog shows). `/tmp/check_math.cpp`-style
|
||||
checks verified the biquads against known responses (LP fc=100 Hz: 50 Hz -> 0.97, 2 kHz -> 0.002;
|
||||
BP fc=1 kHz q=2: 200 Hz -> 0.05, 1 kHz -> 0.95, 4 kHz -> 0.05) -- **keep test sines below
|
||||
Nyquist**: at `dt = 1/10000` a "5 kHz" test tone is the Nyquist point itself and says nothing.
|
||||
The editor is the *compact graph*: nodes as boxes on a `GtkDrawingArea`, click to select (its
|
||||
parameters in the form beside it), **drag from an output port to an input port to wire** (the old
|
||||
two-click protocol is gone), drag a box to move it; **wheel zooms about the pointer, background
|
||||
drag pans, `f` fits, `Ctrl+0` resets**.
|
||||
Its widgets are created once and only shown, hidden and set by `math_dialog_sync()`, so no rebuild
|
||||
can invalidate a live callback handle -- the same shape the measurements dialog uses.
|
||||
`UDPSCOPE_PROBE_MATH_MS` defines two graphs and opens the editor (a construction crash test
|
||||
as much as a state check), and the dump prints each derived channel's extremes beside the
|
||||
source's. Read it on the fast mock, where the two assertions that matter are **`gaps=0`** (the
|
||||
math is on the producer thread, so what it can break is packet capture, never the frame rate)
|
||||
and each `math '<name>': ... N samples vs M time slots` being **equal**, with `math x2` at
|
||||
`y=[-2, 2]`, `math lp` at `y=[-0.998, 0.998]` and `math3` at `y=[-2, 2]`. The `0 visible
|
||||
(order breaks 0, first at -1), y=[0, 0]` tail of that line is **not** a failure: it is
|
||||
`visible_samples()` reading row 0's axis after the window was destroyed, and
|
||||
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger first, so the dump can land in `Waiting`. The live
|
||||
`ch N '<name>' ... y=[..]` lines are the race-free reading of the same thing.
|
||||
`math cost: S slots, N ns/slot (T timed), X ms total est` is the per-slot cost; see the
|
||||
math-cost invariant below for why it is sampled, why it is an upper bound, and why a figure
|
||||
anywhere in 78--101 ns/slot is one reading of the same thing.
|
||||
The probe also asserts the new editor mechanics. Each line carries `(want ...)` and the reading
|
||||
taken from a real run is the reference:
|
||||
* `[math-probe] hit: inside node=0 which=-1, past-edge node=0 which=-1 (want 0 -1 0 -1)` — the
|
||||
output square straddles the box edge, so a hit test that recovers the node by a box test misses
|
||||
every pixel past it. `past-edge node=-1` is the silent disarm that was "edges cannot be added".
|
||||
* `[math-probe] cancel: dialog=cleared (want cleared)` — Cancel must `gtk_window_close()`, not
|
||||
`gtk_window_destroy()`. The probe clicks the **button**, because the defect was in the button's
|
||||
connection and a direct handler call cannot see it.
|
||||
* `[math-probe] view: zoom scale=1.210 anchor kept dx=0.0000 dy=0.0000 (want 0.0000 0.0000)` —
|
||||
a wheel zoom that does not keep the graph point under the pointer reads as the canvas sliding
|
||||
away from the hand. Both deltas must be `0.0000`.
|
||||
* `[math-probe] view: pan+zoom hit node=0 which=-1 (want 0 -1)` — hit tests go through
|
||||
`math_to_graph()`. At the identity view a test that forgot the inverse passes anyway, so this
|
||||
one is taken after a pan *and* a zoom.
|
||||
* `[math-probe] view: fit scale=1.591 all_inside=1 (want 1)` and
|
||||
`[math-probe] view: reset scale=1.000 offset=(0.0,0.0) (want 1.000 (0.0,0.0))`.
|
||||
* `[math-probe] view: pan mode=3 dview=30.0 dnode=0.0 (want 3 30.0 0.0)` — a background drag
|
||||
pans the view and moves no node.
|
||||
* `[math-probe] wire: connect mode=2 in_a=0 (want 2 0)`,
|
||||
`[math-probe] detach: emptied_at_begin=-1 in_a=-1 (want -1 -1)`,
|
||||
`[math-probe] detach: escape restores in_a=0 (want 0)`.
|
||||
* `[math-probe] wire: pick node=1 which=0 -> in_a=-1 off_sample=8.4 tol=3.0 (want 1 0 -1,
|
||||
off_sample > tol)` — the pick is taken at `t = 0.52`, **between** samples 12 and 13 of the 24 the
|
||||
curve is walked at, on a wire whose two boxes are several rows apart. `off_sample` is the measured
|
||||
distance from the pick to the nearest sample point and `tol` the pick radius in the same units:
|
||||
with `off_sample` the larger, a hit test that measured to the sample *points* rather than to the
|
||||
*segments* between them would miss. Taken at `t = 0.5` — exactly sample 12 — the line passed for
|
||||
both implementations and asserted nothing, the same mistake as a hit test taken at the identity
|
||||
view.
|
||||
* `[math-probe] wire: backwards ok=1 order=[0:-1,1:0,2:1] output=2 valid=1 why=[] (want 1 ... 1 [])` —
|
||||
the re-sort. `valid=1` is the assertion; the `order=` string is the reading that explains it.
|
||||
* `[math-probe] wire: self refused=1 why=[a node cannot read itself]` and
|
||||
`[math-probe] wire: cycle refused=1 why=[that wire would make a loop]` — and **nothing is
|
||||
written** on either. Both call `math_connect()` directly, so both assert its out-parameter and
|
||||
neither says anything about the UI.
|
||||
* `[math-probe] wire: self through drag-end node=1 problem=[a node cannot read itself] (want the
|
||||
refusal text, not empty)` — the other half, and the one that matters: the refusal has to reach
|
||||
the **validation line**. The UI path used to screen the self case out before `math_connect()` and
|
||||
then drop its `why` on the floor, so the user drew a wire, nothing appeared and nothing was said
|
||||
while the two lines above passed. No synthetic GTK gesture is reachable headlessly, so the probe
|
||||
sets `drag_mode`/`wire_from`/`press_cx`/`press_cy` and calls `math_canvas_drag_end()` — which is
|
||||
still the handler under test. An empty `problem=[]` here is also how the sync **re-entrancy** was
|
||||
found: setting the op dropdown emits `notify::selected`, whose handler syncs, so the form's own
|
||||
write re-entered `math_dialog_sync()` and the inner call rewrote the label the outer had just
|
||||
written. `MathDialog::syncing` guards it.
|
||||
* `[math-probe] validate: broken problem=[..] label=[..] apply=0; fixed problem=[] apply=1 (want non-empty 0; empty 1)`,
|
||||
`[math-probe] validate: no-selection label=[..] (want non-empty)` — the label is written
|
||||
**above** `math_dialog_sync()`'s early return; a stale message when there is no selection to
|
||||
show is the failure mode.
|
||||
* `[math-probe] validate: source list=6 acquired=6 signals=7 (want list==acquired)` — the
|
||||
dropdown lists exactly the acquired channels, not all signals.
|
||||
* `[math-probe] op pick: nodes 3->4 op_index=7 q_row=1 (want 4 7 1)` and
|
||||
`[math-probe] op pick: index round trip=1 (want 1)`.
|
||||
* `[math-probe] add node: after a delete cells=[(24,20) (24,78) (184,78) (184,20)] overlaps=0
|
||||
(want 0)` — the boxes are put back on the grid, the middle one deleted and one added. The new box
|
||||
takes cell 1, `(184,20)`, which the deletion freed; placing by `nodes.size()` would have put it on
|
||||
`(184,78)`, which is occupied, and an overlapped box is an unclickable one. `overlaps` is the
|
||||
assertion, `cells` the reading that explains it.
|
||||
* `[math-probe] tidy: xs=[24,24,204,384] overlaps=0 (want 0)` — four nodes in three depth columns
|
||||
(the delete above left two roots), so two share the first column and the x values repeat. Only
|
||||
`overlaps=0` is the assertion.
|
||||
Four rules here were wrong first and are worth keeping:
|
||||
* **A `Source` may only name an *acquired* channel** -- `ScopeModel::channels()`, which is what the
|
||||
dialog's dropdown lists, **and both places that create a node must take one from there**.
|
||||
`signals().front()` is the stream's *timestamp* signal: it is first in every CONFIG, it is not
|
||||
acquired, and a graph sourced from it validates and then reads NaN for ever. The dialog's
|
||||
default was fixed first and the pane's `+` was not, which is what "[ch] -> [gain x2] produces
|
||||
only nan" was. The dropdown also *reconciles*: a node whose source is not in the list is moved
|
||||
to what the form is showing, rather than the form showing one channel while the node holds
|
||||
another, and while a source that is not acquired is in the graph the **live validation line is
|
||||
non-empty and Apply is insensitive** (the engine's validator is about the graph -- an output,
|
||||
every input wired above it -- and cannot see the acquired list).
|
||||
* **The ingest must skip a derived channel.** `Acquisition::on_data()` iterates every channel and
|
||||
reads each one from the packet, so a math channel with no signal read *zero* and pushed it --
|
||||
while `push_math_sample()` pushed the real value on the same slot. Two pushes per slot into a
|
||||
ring whose time ring advances once: the values alternated between the real one and 0, and once
|
||||
both rings wrapped the value ring had drifted a whole window off the time ring, so the derived
|
||||
trace disappeared. `ChannelInfo::math` is the flag (`channel.math`) and the loop `continue`s on
|
||||
it. **A derived channel's `values(c).produced()` must equal `live().time().produced()`** -- one
|
||||
push per stored slot -- and the dump prints both as `N samples vs M time slots` for exactly
|
||||
this: the arithmetic on the plot can look plausible long after the pairing is wrong.
|
||||
* **`gtk_window_destroy()` does not emit `close-request`.** The math dialog's pointer is cleared
|
||||
by a `close-request` handler, so a Cancel that *destroyed* the window left `app.math_dialog`
|
||||
pointing at a dead widget: every later `open_math_dialog()` took the "already open" branch and
|
||||
presented it -- a GTK critical, and nothing on screen, which is "I cannot edit a math channel
|
||||
again". Both paths close (`gtk_window_close()`), which emits the signal, lets the handler clear
|
||||
the pointer, and then destroys. The same lesson as the other GTK traps here: a programmatic
|
||||
destroy is not a user close, and only one of them runs the handlers.
|
||||
* **Boxes must not overlap on the canvas.** The first auto-place staggered nodes by 24 px for a
|
||||
132 px box, so every box sat on the one before it, `math_node_at()`'s topmost-first search always
|
||||
returned the last, and a drag or a click landed on the wrong node -- which reads as the dialog
|
||||
ignoring the pointer. The **add-node grid** places in two columns spaced 160 px apart, rows 58,
|
||||
and `math_add_node()` takes the **first free cell** rather than cell `nodes.size()`: placing by
|
||||
the count reuses an occupied cell as soon as anything has been deleted — delete the middle of
|
||||
three and add one and the new box lands exactly on an existing one. The free-cell search uses the
|
||||
same overlap test the tidy probe asserts with, and falls through to cell `nodes.size()` after 64
|
||||
cells rather than searching for ever. The **tidy layout** uses a column pitch of `kNodeW + 48` =
|
||||
180, which is *wider* than that 160 and is what gives a wire's 40-unit horizontal tread its
|
||||
clearance — at 160 the bezier's control points cross and the wire doubles back on itself. The
|
||||
probe prints the box the drag *began over* for exactly this reason.
|
||||
Offering the whole signal list let a graph name something nobody is acquiring (no data for ever)
|
||||
and put the stream's timestamp signal, which has no samples, in it.
|
||||
* **`prepare_math_locked()` runs from `rebuild_locked()`, so *every* plan change re-prepares the
|
||||
filters**: a CONFIG, an enable/disable, and a window change that moves the decimation (that path
|
||||
is `set_target_span()` -> `rebuild_locked()`). Coefficients are a function of the *stored*
|
||||
interval and the filter's state is the old rate's history, so both are rebuilt together.
|
||||
Preparing only on an edit left the cutoff wrong and the state ringing after any window change --
|
||||
that is what "I tested a low pass and it was a mess" was.
|
||||
* **The graph's output defaults to the *last* node, and a node added to a linear graph becomes
|
||||
it.** The output began at index 0 -- the *source* -- so every operation a user built was
|
||||
computed and then thrown away: a gain of 2 and a low pass both produced a channel equal to its
|
||||
input. The output is drawn as the orange dot and settable explicitly, so an earlier node chosen
|
||||
deliberately is kept; this is the default for a graph that has never been given a choice.
|
||||
* **`gtk_spin_button_new_with_range()` can leave the value at the range's *minimum*.** The offset
|
||||
field began at -1e6, and `on_math_param_changed()` reads the widgets, so `x*2` saved as
|
||||
`x*2 - 2000000`. Every numeric field is seeded with its own identity value; the range-minimum
|
||||
default is never a useful starting point for a gain or a corner frequency.
|
||||
* **The form's rows are grouped per *op*, not per field**: gain and offset are one group because
|
||||
`gain + offset` is one op. The offset was a row of its own that nothing ever hid, which read as
|
||||
a property of every node.
|
||||
* **A `GtkSpinButton` holds zero decimals by default**, so every numeric field sets its digits
|
||||
(4 for gain/offset/clamp, 3 for `fc`/`Q`); without that a 12.5 Hz cutoff was stored as 12 and a
|
||||
gain of 0.25 as 0. And the canvas drag reports the *total* offset from where the gesture began,
|
||||
so adding each update's delta to a value the handler then writes back is a runaway box -- it
|
||||
must record the position at `drag-begin` and ignore a drag that did not begin on a box.
|
||||
* **A port hit must name its own node.** The output square is drawn centred on `n.x + kNodeW`,
|
||||
straddling the box's right edge, with a `±kPort` hit radius against a `±kPort*0.5` drawn
|
||||
half-width — so recovering the node with `math_node_at()`, which matches only inside the box,
|
||||
returns `-1` for every pixel past the edge. `math_port_at()` returns the node directly for
|
||||
that reason.
|
||||
* **Every hit test goes through `math_to_graph()`, and every tolerance is a screen distance
|
||||
divided by `view_scale`.** A tolerance left in graph units makes a port unhittable zoomed out
|
||||
and greedy zoomed in. **A probe that tests a hit at the identity view asserts nothing** — the
|
||||
check has to be taken after a pan and a zoom.
|
||||
* **A drag's mode is decided once, at `drag-begin`, and the precedence is port, box, background.**
|
||||
Asking the box first makes the output square unreachable, which is the same geometry as the
|
||||
bullet above.
|
||||
* **A detach empties the input at `drag-begin`.** That is what makes a release over nothing the
|
||||
detach gesture rather than a second verb, and it is why `Escape` has something to restore.
|
||||
* **A backwards wire re-sorts the list; it is not refused.** The evaluator walks the nodes in
|
||||
order, and until now that made the order the *user's* problem — an earlier node's input was not
|
||||
even drawn as connectable. `math_connect()` runs Kahn's algorithm over the **trial** edge set,
|
||||
so a cycle is found before anything is written, breaks ties by the current list index so a graph
|
||||
already in order is not permuted, and remaps **`output`, `selected`, every `in_a`/`in_b`,
|
||||
`drag_node`, `wire_from`, `wire_detach` and `selected_wire.node`** in one block. A missed index
|
||||
silently renames an operation or re-points a wire. Its `edges_of` masks each input by the op's
|
||||
**arity**: a node changed from binary to unary keeps a stale `in_b`, and counting it makes an
|
||||
indegree that never reaches zero and so reports a loop that is not there.
|
||||
* **The validation line is written above `math_dialog_sync()`'s early return.** An out-of-range
|
||||
selection is exactly the state in which a stale message misleads most: the graph can still be
|
||||
broken and there is no form to read instead.
|
||||
* **The line is written from `math_dialog_sync()` and from nowhere else, it ellipsizes, and its
|
||||
tooltip carries the full text.** Three consequences. (1) A *refusal* — "a node cannot read
|
||||
itself", a cycle — is not a state `math_dialog_problem()` can describe, since it is a thing that
|
||||
did not happen, so `drag-end` leaves it on `MathDialog::refusal`, which the sync shows in
|
||||
preference to the graph's own problem and **consumes as it writes**: the message lasts until the
|
||||
next sync, and every mutation syncs. `Apply`'s sensitivity still follows the graph alone, because
|
||||
a refused wire wrote nothing. (2) The label **ellipsizes rather than wraps**, and the tooltip is
|
||||
set beside the text so the two cannot disagree — layout must not depend on content, and this
|
||||
label sits in a dialog whose canvas is `hexpand`. (3) The sync is **not re-entrant**
|
||||
(`MathDialog::syncing`): writing the op dropdown emits `notify::selected`, whose handler syncs,
|
||||
and the inner call rewrote the label the outer one had just written.
|
||||
* **`math_op_index()`, never a cast.** `math_op_at()`'s order is the dialog's — what a dropdown
|
||||
lists and what a session file stores — and `static_cast<guint>(op)` worked only because the
|
||||
two happen to agree today. The session-file rule in this file already says an op is written as
|
||||
its index through `math_op_at()`; this is the other direction of the same rule.
|
||||
* **A `Delete` key removes the selected *wire* and never falls through to the selected node.**
|
||||
A key whose target depends on an invisible selection state is how a graph gets destroyed by
|
||||
accident; the `delete node` button stays the only way to remove a node.
|
||||
- **The math channels' cost is the *producer's*, and the instrument must not be part of it.**
|
||||
`push_math_sample()` runs once per stored slot -- up to the wire rate -- so what a regression
|
||||
here looks like is `gaps`, never a frame time. Three things were taken off that path and must
|
||||
not come back: a `std::vector` *local* (a heap allocation and free per slot; it is the member
|
||||
`math_values_`, `assign()`d), a `Source` node's channel resolved **by name** per sample (a
|
||||
linear scan with a `std::string` compare per candidate, per node, per slot, behind a
|
||||
type-erased call -- `math_prepare()` resolves it once into `MathNodeRt::src`, and every rebuild
|
||||
re-prepares), and a **`fetch_add` on the cost counters**. That last one was 20-25 % of the
|
||||
measured figure: a `lock xadd` costs about what a two-node graph's whole arithmetic does, so
|
||||
counting the slots cost more than the work being counted. The counters are producer-written
|
||||
only, so each is a relaxed `store()` of a value already in hand (`math_slot_count_` and
|
||||
friends). Two things about the figure itself:
|
||||
* **It is sampled, one slot in `kMathCostSample`, and it is an upper bound.** A slot's work is
|
||||
the same order as one `clock_gettime`, so the measured slot carries a clock read. Timing a
|
||||
*run* of consecutive slots to amortise that away **is wrong and was tried**: the producer gets
|
||||
its slots a packet at a time, so a run long enough to be worth timing straddles a packet
|
||||
boundary now and then and charges the measured slots the **wait for the next packet**. At 2000
|
||||
samples a packet a 64-slot run crosses one ~3 % of the time, and a ~1.5 ms gap spread over 64
|
||||
slots reported **799 ns/slot against the true ~80**. One slot is always inside one packet.
|
||||
* **78--101 ns/slot is one reading.** Four identical-config runs spread that far, so the per-slot
|
||||
path is at the noise floor of this harness and a micro-optimisation cannot be shown to work
|
||||
here. Converting `MathRuntime` from eight parallel `std::vector<double>` to one array of
|
||||
`MathNodeRt` was measured and bought **nothing** -- at six nodes the whole runtime is
|
||||
L1-resident either way. It was kept for the shape, not for a number; do not expect a cache
|
||||
argument to pay at this size.
|
||||
@@ -0,0 +1,110 @@
|
||||
# Invariants: the measurements pane
|
||||
|
||||
Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The measurements pane is refreshed on a cadence, and its cost is the *window's*, not the
|
||||
frame's.** A measurement is a statistic of the visible window and a live view moves that window
|
||||
every frame, so "recompute when the window moves" is "recompute every frame" at O(visible
|
||||
samples) *per measurement*. With the pane holding four measurements over a 4.7M-sample window
|
||||
that measured **13.66 ms of tick mean** (baseline 0.41 ms) and cost the run a third of its
|
||||
frames. Three things fixed it, and all three matter: `update_measures()` recomputes at most
|
||||
every `kMeasureRefreshUs` (100 ms; 400 ms when a pass costs more than `kMeasureHeavyUs`), once
|
||||
when the window settles and once per mode change -- and reading *that* backwards is what made
|
||||
it worse first, 457 recomputes in one run, because a pinned window then recomputed every
|
||||
frame; the window is materialized in bounded chunks (never two whole-window f32 temporaries)
|
||||
and the loop holds it **by reference** (copying both vectors per measurement was 300 MB of
|
||||
memcpy a frame); and `measure_stats()` is one shared pass carrying both extremes with their
|
||||
first positions, the sum and the count, so average/min/max/peak-to-peak/amplitude need no
|
||||
pass of their own. **Adding a type must not add a pass** -- that is what `compute_measure()`'s
|
||||
`stats` argument is for.
|
||||
The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and
|
||||
dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker
|
||||
(also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that
|
||||
stroke -- it is created in the same loop, so it does; a new *instrument* should not.
|
||||
The **marks** the pane draws (`set_measure_marks()` -- full-width `h_ruler`s and full-height
|
||||
cursors, i.e. the "lines across the plot") are re-placed every frame from the result, so a
|
||||
throttled pass is a *stepped* ruler: acceptable, because the ruler and the number beside it
|
||||
come from the same pass and agree. What is *not* acceptable is a ruler that appears and
|
||||
disappears, so an invalid pass falls back to the last valid result for its **level** marks and
|
||||
only drops its **time** marks: a level is a property of the signal (a mean, a top, an
|
||||
amplitude) and outlives the window it was measured in, while a time mark is a position *inside*
|
||||
that window and may not. Both are emitted in **one pass after the numbers, never inside the
|
||||
loop that computes them**: that loop has three `continue`s (an unresolvable signal, a channel
|
||||
with no window) and each one was a way for the rulers to blink off and back. Fixing one and not
|
||||
the others is how that bug survived a round of "fixes". `clamp fires: N` in the dump is the other half of this: `clamp_to_data()`
|
||||
compares with a relative tolerance, because clamping to exactly `window_s_` can leave the
|
||||
group's span an ULP above it and a clamp that fires every frame is a re-frame loop, not a
|
||||
clamp.
|
||||
- **A measurement must not read its window raw.** Both passes go through
|
||||
`plot::data::MinMaxPyramid`, which the producer already maintains at amortised O(1) -- so the
|
||||
pane inherits the summary of the new samples rather than computing one, which is what the
|
||||
"evaluate only on new samples" ask comes to here. Throttling the cadence (above) bounded *how
|
||||
often*; this bounds *how much*, and it is the larger factor: 19.7 ms per recompute to 1.2--1.6
|
||||
ms, reading **0.1 %** of a 2 M-sample window. Pass one (`measure_scan_buckets()`) adds whole
|
||||
level-0 buckets -- 64 samples each, carrying min, max an **exact** sum and a finite count -- and
|
||||
locates each extreme to a *bucket*; one raw `measure_scan()` of that bucket alone then pins it
|
||||
to a sample, two buckets at most whatever the window's size. Pass two
|
||||
(`measure_accumulate_bucket()`) folds a bucket's **sums** only when it lies **strictly** on one
|
||||
side of its level (`k.lo > level` or `k.hi < level`): then every sample falls in the same bin and
|
||||
none is *at* the level, so the fold is exact and not an approximation. It asks **separately**
|
||||
whether the bucket enters a side of the hysteresis band (see the invariant below), because a
|
||||
single test against `level ± h` refuses exactly the buckets a crossing passes through. A bucket
|
||||
wholly past a threshold holds that crossing at its first sample, exactly -- which is where the raw
|
||||
walk would have found it. It refuses a bucket that straddles the level, one that enters a side
|
||||
somewhere inside itself, and one holding a non-finite sample (the raw walk takes the first
|
||||
*finite* sample of the new side and an aggregate cannot say which that is); the caller then opens
|
||||
that bucket. **Nothing may be folded before a refusal** -- a refused bucket goes to
|
||||
`measure_accumulate()`, which adds the same samples again, and sums added above the `return false`
|
||||
double-counted every opened bucket, reading `top=0.614304` on a unit sine against the 2/pi it must
|
||||
be. Four things this rests on, each of which breaks it silently:
|
||||
* **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read
|
||||
exactly `2` on a unit sine and `top` exactly `0.636620` (2/pi). A bucket's sum is a sum.
|
||||
* **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls
|
||||
`pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's
|
||||
absolute index space -- the same space `live_window_locked()` answers in, since a channel's
|
||||
stride is always 1. The snapshot's pyramid is built from sample 0 and never dropped from, so
|
||||
its space is the snapshot's own. `MeasureWindowReader::origin_` is what turns one into a
|
||||
window offset, and a mismatch there measures the wrong samples rather than failing.
|
||||
* **The raw path stays, and stays exact**, for the window's partial edge buckets, for the
|
||||
samples produced since this frame's `acquire_all()` (one frame of production, so bounded), and
|
||||
for the straddling buckets. A window shorter than a bucket degrades to O(window) and must
|
||||
still be right.
|
||||
* **Both passes walk the same fetched bucket list** (`App::measure_bucket_scratch`, reused
|
||||
across recomputes -- a 2 M-sample window is 32 k buckets). Fetching twice is a second walk of
|
||||
the pyramid for nothing.
|
||||
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
|
||||
anything.
|
||||
- **A crossing is counted with hysteresis, and the band moves the *edge decision* only.**
|
||||
`MeasureLevel::hysteresis` is a Schmitt trigger about `level`: an edge is counted once the signal
|
||||
has passed `level + h` going up or `level - h` going down, and a sample inside the band decides
|
||||
nothing. Without it `period`, `frequency` and `pulse duration` are unusable on anything real --
|
||||
the mid level is exactly where a waveform moves *fastest*, so an interferer faster than the
|
||||
fundamental recrosses a single level several times per true crossing. Measured on a 5 Hz sine with
|
||||
a 500 Hz tone at a tenth of the amplitude (`tools/mock_udpstreamer.py --noise 0.1`): 9 true
|
||||
crossings counted as **79**, `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz, against
|
||||
`period=0.200188` with the band. Four rules, each of which was wrong first:
|
||||
* **`measure_set_level()` is the only place the band is sized**, and `src/ui.cpp` calls it rather
|
||||
than assigning `.level` itself: two expressions for the width is how the raw walk and the bucket
|
||||
path end up disagreeing about where a crossing was. `h = min(0.10 * (hi - lo), 0.5 * min(hi -
|
||||
level, level - lo))`. The 0.10 is measured -- 5 % reads a 0.1 interferer and fails at 0.2
|
||||
(`period=0.075`), 10 % holds to 0.3 and to white noise at sigma=0.05.
|
||||
* **The clamp is load-bearing.** `PulseDuration` lets the user put the split near an extreme, and
|
||||
a fixed tenth of the span there puts the upper threshold *above every sample*: the walk never
|
||||
decides that side and the measurement reads nothing at all, which is worse than reading
|
||||
something noisy. On a ringing 3.3 V logic signal at a 90 % split: `enters=0` with a fixed band,
|
||||
`enters=4 period=0.200000` with the clamp.
|
||||
* **The `sum`/`count` split stays at the bare `level`.** That split *is* `Top`/`Bottom` -- the mean
|
||||
above and below the threshold -- so a band there changes a documented exact reading. `top` on a
|
||||
unit sine must still be `0.636620`.
|
||||
* **`h = 0` must reduce exactly to the single-level walk**, including the `v == level` skip, so the
|
||||
band is a parameter of the engine rather than a mode of it.
|
||||
The cost is the noise's, not the band's: the clean fast mock reads `raw=119412 (0.1%)` of 215 M at
|
||||
1.41 ms a recompute and with the 0.1 interferer `raw=2.0%` at 1.96 ms. A
|
||||
`/tmp/check_measure.cpp`-style check is how the walk itself was verified (`g++ -std=c++20 -O2 -I
|
||||
src ... src/measure.cpp`): over a clean sine, sines with 0.1 and 0.3 interferers, 10 %/50 % duty
|
||||
squares and a 90 % threshold, the **bucket walk must equal the raw walk exactly** -- same
|
||||
`enters`, same `count[]`, identical `first_enter`/`last_enter` to 0.0 -- which is the assertion
|
||||
that the fast path is a fast path and not an approximation. The app's reading is
|
||||
`[measures-probe]` against `--noise 0.1`.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Invariants: the plot, the figure and the layout
|
||||
|
||||
How `plot::Figure`/`plot::Plot` may and may not be driven, what a row's state belongs to, the layout editor's overlay, and the rule that layout must not depend on content.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **`plot::Plot` cannot remove or retarget a trace.** Hence fixed trace slots created
|
||||
up front and rebinding via `SlotSeries`; a *name* change (legend text) is the one
|
||||
thing that forces a rebuild. Cursors are parked outside the axis range instead of
|
||||
removed.
|
||||
- **A `Figure`'s row count is fixed at construction**, so `RowSet::rebuild_all()` builds
|
||||
a *new* figure and the area is handed it (`PlotArea::set_figure()`); it costs no GL
|
||||
state. That makes one path for "a row's traces changed" and "the row count changed",
|
||||
and it is why `rebuild_all()` restates a pinned event window (`pinned_pre_`/
|
||||
`pinned_post_`) instead of re-framing on the live edge — a drop during trigger mode
|
||||
would otherwise throw the frozen view onto the stream, where nothing moves it off
|
||||
again until the next latch. `PlotArea::create()` is handed `App::placeholder_plot`,
|
||||
never a row: the area keeps a reference to the plot it was created with, and a rebuild
|
||||
destroys every row.
|
||||
- **A trace is a slot with a name *and* a channel; a name alone is not one.** `Row::channel[s]`
|
||||
is re-resolved by `RowSet::bind()` every frame (slots are identified by name, see above), and
|
||||
`-1` means the model built no channel for that name. Some names can *never* bind: the stream's
|
||||
**time signal** is skipped by `build_channels_locked()` however often it is enabled, so a slot
|
||||
holding it has a name for ever and data never. The plot showed nothing for it, which is why it
|
||||
went unnoticed, but the cursors pane's `mix` mode drew one line per *named* slot and so gave
|
||||
each phantom a row that could only read `--` — "the pane shows a lot of empty signals that do
|
||||
not exist", six lines over three real traces. Two halves to the rule. (1) Anything that counts
|
||||
traces counts `channel[s] >= 0`, not `!signal_name[s].empty()` — and a per-frame bind means the
|
||||
cursors pane cannot key its rebuild on `generation()` alone, hence `App::cursors_pane_bound`, a
|
||||
bit per row/slot (`kMaxRows * kSlotsPerRow` is 32, so one `std::uint32_t`): a trace that
|
||||
appears without a structural change still gets its line. (2) `apply_layout()` refuses a name
|
||||
that cannot produce a channel (`ScopeModel::is_time_signal()`), because such a name was
|
||||
**sticky**: `collect_layout()` writes `signal_name` back verbatim, so one that got into a
|
||||
session file was re-saved every session for ever. The refusal also heals an existing file on
|
||||
the next save. How the names first got in is unexplained — the drop path already refuses a
|
||||
signal with no channel — most likely an older build or a CONFIG whose time signal was not yet
|
||||
classified as one; the fix is the stickiness and the counting, not a guess at the origin.
|
||||
- **A row's contents are state of the `RowSet`, not of the figure.** `insert_row()` and
|
||||
`remove_row()` shift that state with `carry_row()` and let `rebuild_all()` re-deal the
|
||||
figure handles, so inserting above row 1 moves what row 1 plotted down to row 2 rather
|
||||
than renumbering the rows under the user. `carry_row()` deliberately copies fields
|
||||
instead of assigning a whole `Row`: a `Row` also holds the figure handles (`plot`, the
|
||||
axis and cursor ids) and the `SlotSeries` objects the traces point at, and those belong
|
||||
to the row's *position*. It clears the destination's slot bindings for the same reason —
|
||||
the next `bind()` re-resolves them, and without the clear the frame in between draws the
|
||||
moved row's samples under the new row's legend.
|
||||
Both call `rebuild_all()` themselves, so their callers must re-point the area
|
||||
(`PlotArea::set_figure()`) directly; `relayout_areas()` is no help, since it only acts on
|
||||
a *dirty* row.
|
||||
- **The layout editor's buttons are *separate* overlay children, never one transparent
|
||||
child covering the area.** A `GtkOverlay` child is picked wherever its allocation is, and
|
||||
`gtk_widget_set_can_target(FALSE)` takes a widget's children out of picking with it, so a
|
||||
full-area `GtkFixed` would swallow every pan, drop and hover the editor is not interested
|
||||
in. Each button is therefore a small widget aligned to a corner or the centre and placed
|
||||
by its **margins**, and the overlay is only measured from its main child, so moving them
|
||||
cannot reach the plot's layout.
|
||||
They are placed from `FigureLayout::rows` — the rects the frame was *drawn* from —
|
||||
divided by `PlotArea::device_scale()`, because a `Rect` is framebuffer pixels and a GTK
|
||||
margin is logical ones. Reading the figure's own rects rather than dividing the widget
|
||||
height by the row count is what keeps a `×` on the row it names when the rows stop being
|
||||
an equal division. `sync_layout_editor()` runs every frame on purpose:
|
||||
`gtk_widget_set_margin_*()` returns early on an unchanged value, so a settled layout costs
|
||||
the comparisons and nothing else, and a resize needs no notification.
|
||||
- **Trace slots are identified by signal name, not channel index.** Enabling a signal
|
||||
or a new CONFIG renumbers the channel list, so an index would silently re-point a
|
||||
trace at another signal.
|
||||
- **Layout must not depend on content.** The y gutter is reserved at the widest label
|
||||
`TickFormat::Si` can produce *for an axis with no unit* — a sign, three significant
|
||||
figures and a one-glyph prefix, measured and not counted in characters — and only ever
|
||||
grows; the status label is ellipsized so
|
||||
its text cannot drive the paned divider. Both exist to stop per-frame relayout.
|
||||
The reservation deliberately does **not** cover `format_si()`'s `%.3g` fallback
|
||||
(`-1.23e-45`), which it used to: that form needs a value past 1e24, so it bought a third
|
||||
of the gutter for a label no stream produces, and the floor being a monotone maximum over
|
||||
the *measured* need means one that did appear widens the gutter for good anyway. Erring
|
||||
wide is not free here — it is a third of the plot's width.
|
||||
And **nothing may add slack to the gutter it is told**: `note_y_gutter()`'s argument is
|
||||
`layout().y_axis_width`, which is `max(measured, floor)` — partly our own answer coming
|
||||
back — so a constant added to it compounds. See the ratchet reading under `layout over N frames` in
|
||||
[probe-readings.md](probe-readings.md).
|
||||
The same rule is why the activity badge is one glyph in three colours rather than
|
||||
three glyphs, why the `rate` field collapses to one figure or a range instead of a
|
||||
per-channel list, and why `format_elapsed()` steps to a coarser unit instead of
|
||||
letting the counter grow: each is a status field whose *width* would otherwise depend
|
||||
on the stream, and the status row is directly above the plot.
|
||||
The reservation goes on the **figure** (`Figure::set_min_y_gutter()`, added to the
|
||||
plotter for this), not on the rows: `Figure::layout()` clears every row's
|
||||
`set_min_y_gutter()` to measure what its own labels need, so a floor written onto the
|
||||
rows survives exactly until the next layout.
|
||||
- **The right-hand band is a want, and the left gutter is a measurement.** The two sides of
|
||||
the plot are not symmetric in kind: the left holds the y labels, so it is measured and
|
||||
floored; the right holds nothing, so there is nothing to measure and `RowSet` simply asks
|
||||
for half the widest time label plus the label padding (`right_gutter_px_`, reserved in the
|
||||
same one-shot block). Half a label is the figure that matters: an x tick label is
|
||||
*centred* on its tick and a live axis is `[-window, 0]`, so there is always a tick exactly
|
||||
on the axes' right edge, and with `axes` flush to the viewport half of that label was
|
||||
drawn outside the plot. Both go on the **figure** and for the same reason — the rows have
|
||||
to end on one column as well as start on one — which is why `apply_gutters()` applies the
|
||||
pair and `rebuild_all()` calls it: a new `Figure` carries neither.
|
||||
- **A trace's colour is indexed by *signal*, not by trace slot.** `Row::color_index`
|
||||
stores it and `signal_color_index()` (ui.cpp) computes it from the signal list, so a
|
||||
signal dropped on two rows is one colour and the signal panel's chip names a trace
|
||||
unambiguously. `plot::Plot` fixes a colour at `add_trace()`, so changing one needs a
|
||||
figure rebuild — which `assign()` already forces. A signal on no plot gets
|
||||
`RowSet::unplotted_color()` (grey) rather than no chip, so the panel's rows stay the
|
||||
same height.
|
||||
- **`RowSet::signal_color()` is the only route to a trace's colour.** The user can override the
|
||||
palette entry per signal (clicking its chip; `set_signal_color()` / `clear_signal_color()`,
|
||||
keyed by **name**, because a palette index is positional and a CONFIG renumbers), and that
|
||||
override is exactly the state in which two routes to a colour disagree. So `install()` builds
|
||||
the trace through this call and all three chip sites — the signal panel, the measures pane and
|
||||
the cursors pane — read it too; none of them may index `palette_color()` itself. An override
|
||||
marks every row holding the signal dirty (`mark_signal_dirty()`, not `assign()`: what the slot
|
||||
*holds* did not change) and the rebuild happens on the frame tick via `relayout_pending`,
|
||||
because `relayout_areas()` is the one place that builds a figure and re-points the area at it.
|
||||
`UDPSCOPE_PROBE_COLOR_MS` reads the result back out of `plot::Plot::trace()` and out of the
|
||||
chip's own markup, since asking the resolver twice would assert nothing.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Invariants: readouts, cursors and the value formatter
|
||||
|
||||
What a readout is allowed to quote, how the cursors are read back, and the one formatter both panes share.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **A readout quotes a stored sample, never a value between two of them.** The hover
|
||||
crosshair and the cursors pane's `mix` values are one call, `Plot::crosshair_value()`, and it
|
||||
reports the **nearer** of the two samples bracketing the x (a tie reads the earlier). Upstream
|
||||
interpolated linearly, and that is wrong twice over here: ingest decimation is a *boxcar
|
||||
average* over `time_stride` samples, so a number between two stored samples is a second
|
||||
estimate laid over the first that nothing on screen or in an export holds; and past the sample
|
||||
spacing the polyline is a line the renderer drew, so a point on it reports the drawing. The
|
||||
change is in `deps/plotter`, which keeps the blend as `CrosshairValue::Interpolated` for a host
|
||||
whose x is a quantity it is willing to interpolate — the nearest sample is the **default** and
|
||||
**udpscope never states the other**, so a `set_crosshair_value_mode()` call appearing here is a
|
||||
regression, not a feature.
|
||||
**Both the mark and the number come from one call, `Plot::crosshair_point()`**, which returns
|
||||
the quoted sample's own **x** as well as its y — so every mark this app draws for a readout is
|
||||
*snapped to the sample it quotes*. The library's `CrosshairRow::marker` and the cursors pane's
|
||||
`mix` crossing annotations (`place_crossings()`, which must use `crosshair_point()` and not
|
||||
`crosshair_value()`) are both placed from it. Two consequences that look like defects and are
|
||||
not: the mark **sits beside the polyline at a deep zoom** (one y feeding both the number and the
|
||||
dot is what keeps them from disagreeing), and a crossing dot **sits a little off its own
|
||||
vertical cursor line** — up to half a sample period, invisible at a whole-window view and the
|
||||
width of the panel at a few samples across, which is the only zoom at which anyone is asking
|
||||
which point it is. The line says where the *marker* is; the dot says which *datum* is quoted.
|
||||
The hover mark is also deliberately *larger* than a trace's own sample dots
|
||||
(`Style::crosshair_marker_px` 9 against `marker_px` 3), because a snapped mark lands among them
|
||||
and at their size reads as one more sample. The guards are the library's
|
||||
`crosshair_value_is_the_nearest_sample`, `the_crosshair_marker_snaps_to_the_sample_it_reports`,
|
||||
`crosshair_interpolated_mode_blends_the_two_samples` and
|
||||
`the_crosshair_mark_is_bigger_than_a_sample_dot`, plus `[cursors-probe]`'s `first mark at x=..
|
||||
y=..` agreeing with the pane's first value.
|
||||
- **`format_measure()` is the one formatter for both panes, and its SI prefix does not depend on
|
||||
there being a unit.** The cursors pane's values and the measurements pane's `text=` go through
|
||||
it, so a change reaches both. The prefix ladder used to be inside `if (!unit.empty())`, which
|
||||
made two decimals of a small unitless value read `0.00` — the reading gone, not merely coarse —
|
||||
and that is a whole class of channels, not a corner: `measure_unit()` returns `""` for any signal
|
||||
whose CONFIG declares no unit, and a **math channel never has one**. The regression net could not
|
||||
see it because `tools/mock_udpstreamer.py` declared `unit="V"` for every data signal; it now takes
|
||||
**`--unit ''`**, and that is the flag to run the measures and cursors probes under when touching
|
||||
this (`text=[636.62m]`, `● sine=585.24m`). With no unit the prefix is written **tight** against
|
||||
the number — `1.34m`, not `1.34 m` — because the space exists to separate the number from its
|
||||
unit and a spaced `m` alone reads as metres; with one it stays spaced and the prefix joins the
|
||||
unit (`636.62 mV`). Below `1e-12` the ladder runs out, and `%.2f` would print `0.00` again, so
|
||||
that range falls back to `%.3g`.
|
||||
- **The cursors are read back from the plots only while a gesture is on that row.** `Cursor::x`
|
||||
is the *free* pointer position while a drag runs and the snapped value otherwise -- which is
|
||||
what the app itself wrote a frame earlier, or the snap of it. A tick that reads a row on
|
||||
every frame therefore adopts its own echo back, and -- because the vertical pair is one value
|
||||
shared by every row -- a row that has not been re-written yet hands the *previous* shared
|
||||
time straight back. That is the "drag it and it springs back" defect, and the fix is
|
||||
`RowSet::sync_cursors(int drag_row)`: the tick passes the row whose controller reports
|
||||
`grabbed_cursor() != kInvalid`, and every other frame does nothing. The values are echoed
|
||||
back with `CursorPlacement::Free` for the same reason -- the controller snaps at the end of
|
||||
the gesture (`Controller::end_drag()`), and snapping the echo fights the hand that is still
|
||||
moving the cursor.
|
||||
- **The cursors' on/off is one flag on the `RowSet`, never a copy per row.** It is one
|
||||
piece of state (the toolbar toggle) and the mode is one value; a per-row copy is something
|
||||
`insert_row()`'s `clear_row()`, or any rebuild, can clear behind the user's back, which is
|
||||
how a row added while the cursors were already on came up with its instruments parked
|
||||
while every other row showed them. The same goes for the vertical pair's `x`: it is one
|
||||
stream time for the figure, so it lives on the `RowSet` too, and `Row` keeps only what is
|
||||
genuinely per row (the rulers' values, the traces, the colours).
|
||||
- **Every instrument this app *computed* is out of the pointer's reach.** The trigger marker and
|
||||
the measurement marks are cursors created with `Plot::add_cursor()`/`add_h_ruler()`, and a cursor
|
||||
is grabbable by default -- so each takes `set_cursor_selectable(id, false)`. **Parking it outside
|
||||
the axis is not enough**: the controller holds a marker for the gesture's life and follows the
|
||||
pointer between frames, so a readout parked at `kParked` could still be dragged. The trigger
|
||||
marker says where the scope put the edge and a measurement mark says what a measurement measured;
|
||||
neither is something a user may move. The dump prints `readout instruments locked: N`, which must
|
||||
be 1 -- a drag is not something a probe can perform, so the state is what is asserted.
|
||||
@@ -0,0 +1,160 @@
|
||||
# Invariants: rendering — index mapping, gather, LOD and grading
|
||||
|
||||
How the renderer maps a visible range onto samples, what `gather()` emits and relative to what, and why a dense trace is drawn one pixel column at a time.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The renderer maps the visible range onto indices through the series' own x, never the
|
||||
`dx()` grid.** `deps/plotter`'s `Renderer::draw_traces` asks `Series::neighbours()` for the
|
||||
bracketing samples. A grid walk is wrong for exactly the series this app plots: `dx()` here is
|
||||
a nominal spacing (the LOD level, a coarse snap) while the real stamps are the time ring, so
|
||||
the window it computes lands inside the visible range and the two end segments — the ones that
|
||||
draw into the left and right margins — are never emitted. That is the "empty margin when
|
||||
zoomed in" defect. The library test is
|
||||
`a_sparse_window_between_samples_still_inks_both_margins` (a stub whose `dx()` lies, with the
|
||||
window strictly between two samples). It is the guard, not the probe: `[trig-probe/idx]` now
|
||||
computes `got` from the same call the renderer makes, so it cannot catch a renderer that went
|
||||
back to the grid — what it still reports is the drift that made the walk wrong (`dx_ratio`).
|
||||
- **`neighbours()` is a binary search over the real stamps in *every* series here, the
|
||||
snapshot's included.** `EventSeries` answered from the `(x - x0()) / dx()` grid — the walk the
|
||||
invariant above condemns: `dx()` is the *mean* spacing of the snapshot's stamps and the stamps
|
||||
are an arrival clock, so grid and clock drift apart by whatever that clock wandered over the
|
||||
capture. The renderer picks its visible index window from this call, so the drift arrived as a
|
||||
**constant offset in seconds** between the axis and the gathered geometry — and a constant
|
||||
number of seconds is a *doubling share of the screen* on every zoom step: 8 ms over a 2 s
|
||||
capture is invisible, and seven halvings later it is 374 px of blank left margin on a 716 px
|
||||
axis, with the ink pushed off the right-hand edge by the same amount. That is the "zooming
|
||||
into a held event clips it more and more from the left while the crosshair still reads the
|
||||
right values" report; the crosshair was right because it reads `x_at()`, a real stamp.
|
||||
`ChannelBuffer` always searched; the two now agree. **A perfectly uniform source cannot show
|
||||
this at all** — three probe runs against the ideal mock were clean — so the guard needs
|
||||
`--clock-jitter` (see `UDPSCOPE_PROBE_EVENTZOOM_MS` above).
|
||||
- **The `Series` grid contract does not hold for `ChannelBuffer`.** x is a streamed
|
||||
timestamp, so `dx()`/`x0()` report a nominal grid (LOD level, coarse snap) while
|
||||
`gather()`, `x_at()`, `neighbours()` and `x_bounds()` use the real time ring.
|
||||
Nothing may walk that grid to find *which* samples a range covers — the renderer did,
|
||||
and it lost the two end segments (see the margin invariant above). `gather()` emits x
|
||||
relative to an origin because absolute epoch seconds do not survive `f32`.
|
||||
- **`gather()` emits x relative to an origin *the caller names*, in every series here**,
|
||||
`EventSeries` included, and the consumer adds that same origin back in `f64`. Two separate
|
||||
rules live here and both were learned from a defect.
|
||||
(1) **It must be relative at all.** An event's stamps are small (edge relative,
|
||||
`[-pre, +post]`), so emitting them absolute does not overflow anything and does not look
|
||||
like a bug — it just draws the whole capture `x0()` = one pre-trigger window to the left of
|
||||
the axis. That was the "half the plot is empty / the marker sits at the end of the data /
|
||||
nothing is drawn but the crosshair reads values" defect: the crosshair reads `x_at()`, which
|
||||
was right all along. `[trig-probe]`'s `data=` vs `drawn=` is the guard.
|
||||
(2) **The origin is the *view's* minimum, not `x0()`.** `x0()` is the start of the
|
||||
**retained window**, so the f32 had to resolve 2^-24 of the whole ring however little of it
|
||||
was on screen: 1.9 µs over 20 s of retained data, which at a view 10 µs wide across a 716 px
|
||||
axis is over a hundred pixels between where a sample is drawn and the x `x_at()` quotes for
|
||||
it — and it halved on every zoom out, so it read as "zooming in makes the crosshair select a
|
||||
point beside the data". `Renderer::draw_traces` passes `x_range.min`, making the error a fixed
|
||||
4e-5 of a pixel at any zoom. The parameter sits **before** `out_xy` deliberately, so a call
|
||||
site that was not thought about is a compile error rather than silent drift. A caller that has
|
||||
no view in mind may still pass `x0()`, and `[trig-probe]`'s `drawn=`/`data=` does exactly that
|
||||
on purpose: it reads the series' *whole* span, which is the widest relative value a gather can
|
||||
be asked to stage. The two guards are upstream's
|
||||
`a_gather_origin_at_the_view_keeps_a_deep_zoom_on_the_sample_it_quotes` and the
|
||||
`trace ink:` line's `drawn-vs-quoted` figure.
|
||||
**A probe that recomputes what the renderer gathers must pass the same origin the renderer
|
||||
does** (`[ink]` and `[zoom-probe]` both pass the axis minimum), or its reconstruction stops
|
||||
being the renderer's and its pixel readings mean nothing.
|
||||
- **Every series bound to a trace must carry a real `MinMaxPyramid`**, including an
|
||||
immutable one. `MinMaxPyramid::level_for()` descends to level 0 — raw samples — on a
|
||||
pyramid with no levels, whatever `n` is, so a series that answers `pyramid()` with an
|
||||
empty one is re-gathered sample by sample on every frame it is bound. That was the
|
||||
"large data is laggy" defect: `EventSeries` had no pyramid, and a held 2 s capture at
|
||||
1 MS/s staged 6 million segments per frame for as long as it stayed on screen. The
|
||||
pyramid lives on `EventSnapshot`, not on `EventSeries`: the snapshot is immutable, so it
|
||||
is built once in `publish_event()` inside the `O(n)` pass that already computes the y
|
||||
bounds, and the same channel on two rows shares it. It costs ≈1 byte per sample per
|
||||
channel. `frame path:`'s `segments` is the guard.
|
||||
**"Every" includes the pause**, and that is the half this invariant was written without and
|
||||
which then went wrong for real: `publish_pause_view()` filled `stamps`, `values` and `y` and
|
||||
left `pyramids` empty, so a *paused* view was re-gathered raw on every frame. Both halves of
|
||||
the cost are readable, and the second is the one a performance line cannot show.
|
||||
`frame path:` read `raw=21 lod=118` over a 1.6 s pause against `raw=1 lod=170` for the same run
|
||||
live, with the draw mean 12.34 → 18.18 ms — and the **trace changed appearance**, which is what
|
||||
gets reported. `stage_trace_density()` grades a column against *its own peak* from a `1/height`
|
||||
deposit per segment, so a column fed ~660 raw inter-sample segments accumulates a far spikier
|
||||
distribution, and a far larger peak to normalise against, than one fed the one or two bucket
|
||||
envelopes the pyramid gives: the paused trace read as a hard bright core with its dwell tail
|
||||
dimmed out. **A pause is not a cheaper case than a capture — it freezes the same rings**, so
|
||||
anything `publish_event()` does to a snapshot, `publish_pause_view()` owes too. Building it is
|
||||
not free (see `publish cost:` below): it took the pause publish from 6.15 ms to 22.31 ms, a
|
||||
one-off producer stall against ~5 ms a frame of draw for the pause's whole length, which at
|
||||
~60 fps repays about thirty times over on a 1.6 s pause — and the producer absorbs it without
|
||||
losing packets (`gaps=0` with and without a pause, the paused run at an 86 ms publish).
|
||||
**The check that the look is restored is `frame path:` across the pause, not the cost line**:
|
||||
a paused run must read the same path as a live one — measured `raw=1 lod=555 columns=555
|
||||
graded=555` against `raw=0 lod=373 columns=373 graded=373`, same 24711-segment worst frame,
|
||||
segment mean within 5 % — where the defect read `raw=21 lod=118`. A `raw` in the tens over a
|
||||
pause is this returning, and it is the appearance as much as the cost.
|
||||
- **`EventSeries::gather()` emits a bucket's envelope, not every `2^level`-th sample.**
|
||||
Point decimation hides exactly the spike the capture was triggered on; a scope has to
|
||||
draw a bucket's extremes. It needs no index rebasing, unlike the ring: the snapshot's
|
||||
pyramid is built from sample 0 and nothing is ever dropped from it, so `window_base()` is
|
||||
0 and a window index *is* the absolute one.
|
||||
- **A trace denser than the axis is drawn one pixel column at a time, and that is what keeps
|
||||
the plots free of moiré.** The envelope staged as a polyline banded visibly and *crawled*
|
||||
under a pan on exactly the signals this app is pointed at — a high rate, a high frequency or
|
||||
noise — and three things compound to produce it, none a bug alone: `level_for()` sizes a
|
||||
bucket at one to two pixel columns, `Envelope` emits two points per bucket so each
|
||||
contributes a near-vertical min→max hairline *and* a near-vertical connector (≈3.6 per
|
||||
column per trace), and every one lands at a **fractional** x because a trace is deliberately
|
||||
not snapped. Under src-alpha compositing a column's brightness is then a non-linear function
|
||||
of the sub-pixel phases overlapping it, the pattern repeats at the beat between the bucket
|
||||
pitch and the pixel pitch, and a pan sweeps the beat. `deps/plotter`'s
|
||||
`Renderer::stage_trace_columns()` emits one snapped vertical segment per integer column
|
||||
instead, so the trace has **one** phase and there is nothing left to beat. Four things to
|
||||
know before touching it, all upstream in `docs/data-and-performance.md`: the gate is the
|
||||
gathered **density** (≥2 points per column) and `Envelope`, never the LOD level — the raw
|
||||
path at 1.5–64 spp beats the same way, and the density term is also what keeps the reduction
|
||||
off a *sparse* window whose trace is the two segments entering and leaving the frame; a hole
|
||||
stays a hole; disjoint neighbours are joined so a fast edge keeps its join; and it is the one
|
||||
carve-out in `u_snap`'s "never anything drawn at a data position" rule, because a column *is*
|
||||
a pixel column.
|
||||
**It needs a thin ribbon to be visible at all** — a window whose buckets span several periods
|
||||
paints a saturated block and hides the beat — so a screenshot of a slow sine at a whole-window
|
||||
view says nothing either way. The guards are upstream's
|
||||
`a_dense_trace_is_drawn_as_whole_pixel_columns`, which counts the **distinct coverage levels**
|
||||
in the ink (4 against the polyline's 126; a count of *partially covered* pixels separates
|
||||
nothing, 577 against 652, because a 1.5 px stroke's fringe always spills into the next
|
||||
column), and `frame path:`'s `columns=` field here. `trace ink:` deliberately cannot see this
|
||||
path: it recomputes the **gathered** points, which the reduction is downstream of, so its
|
||||
`drawn-vs-quoted` reads 0.0000 either way and remains the guard for the gather origin alone.
|
||||
- **A reduced column is graded by dwell, and the grading may not undo the reduction.** The
|
||||
column above is honest about *where* the signal went and silent about *how long it was there*
|
||||
— a column's min and max are identical whether its samples fill the span or pile at its two
|
||||
ends, so a ramp and a pulse are one flat band. `Style::intensity_grading` grades it instead
|
||||
(bright where the signal sits, faint where it only passes through, the digital-phosphor look),
|
||||
and **udpscope turns it on; the library's default is off**, so other hosts are not restyled by
|
||||
an upgrade. The switch is a one-shot in `tick()` guarded by `App::style_stated`, not a line in
|
||||
the window build: `PlotArea::renderer()` is null until the area is realized, so there is no
|
||||
earlier place to state a style. Three things to know here, the rest upstream in
|
||||
`docs/data-and-performance.md` (the `1/height` dwell weighting, the one-column accumulator, the
|
||||
per-column-peak normalisation, the quantised alpha steps):
|
||||
* **The finer gather level is floored at 1 whenever the flat path would have used the
|
||||
pyramid.** Grading asks for `samples_per_pixel / intensity_sub_columns`, and
|
||||
`MinMaxPyramid::level_for()` answers **0 — raw samples** — as soon as that falls under one
|
||||
bucket (`kBucket` is 64). Unfloored, every moderately dense frame became an O(visible)
|
||||
gather: at 1.3 MS/s over a 2 s window, 172 samples a pixel, `raw=1154 lod=0` against the flat
|
||||
path's `raw=710 lod=453`, and 2.30 → 6.36 ms of draw mean. The floor degrades the *look*
|
||||
(fewer sub-columns than asked for, down to what the coarsest buckets give — still two or
|
||||
three per column at 64 samples a bucket) and never the path. It is also why
|
||||
`intensity_sub_columns` past about 8 buys nothing: the bucket size is the limit.
|
||||
* **`graded=` must equal `columns=`** in `frame path:`, and `UDPSCOPE_NO_PHOSPHOR=1` is the A/B
|
||||
that makes either line readable against the ungraded path (see
|
||||
[probe-readings.md](probe-readings.md)).
|
||||
* **The steps are draw calls, not per-segment alpha.** `LineInstance` is four floats of
|
||||
geometry and the colour is a **uniform** (only `TextInstance` carries `a_colour`), so the
|
||||
gradient is `intensity_steps` `LineBatch`es flushed at different alphas. That is why the
|
||||
segment mean rises from 131 to 1340 on the same frames — the rise is the tiers, not extra
|
||||
geometry per column, and the bound is still `axes.w` (measured 14.5 runs a column in the
|
||||
library's own guard, 8303 segments over a 572 px axis).
|
||||
The guard is upstream's `a_dense_trace_can_be_graded_by_dwell`, which draws one frame both ways
|
||||
and profiles the band's outer fifths against its middle: flat reads **191/191**, graded
|
||||
**191/131**. That 0.69 is a *tier* ratio, not the dwell ratio — `intensity_gamma` (0.45) and
|
||||
`intensity_floor` (0.3) compress it deliberately, the floor so that a fast riser, one
|
||||
sub-bucket deep everywhere, is never dimmed out of existence.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Invariants: the session file, the export and the source chooser
|
||||
|
||||
What a session persists and in what order, when a save is refused, and when an export is collected.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the
|
||||
one that was closed: the window, the rows and their slot signals, the calibration, **the enabled
|
||||
signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the
|
||||
*overrides*, so a stream's new signals still take the positional palette and a saved session does
|
||||
not freeze a colour that is meant to follow the signal list), **the math channels with their
|
||||
graphs** and **the measurements pane**. Signal identity
|
||||
is the *name* throughout (a CONFIG renumbers channels), 'enabled' is read off the channels the
|
||||
model built -- an enabled signal is exactly one that produced a channel -- and `apply_layout()`
|
||||
installs the graphs **before** assigning the rows, since a row may hold a math channel's name and
|
||||
that name is only a signal once its graph is in. Two format rules, both learned the hard way:
|
||||
write an op or a measure *type* as its index through `math_op_at()`/`measure_type_at()`, never as
|
||||
the raw enum value -- those accessors' orders are the dialog's, not the declaration order, so
|
||||
writing one and reading the other silently renames operations. And a top-level key written after
|
||||
a `[[table]]` belongs to *that table* in TOML: `enabled = [...]` emitted after `[[calibration]]`
|
||||
was parsed into the last calibration record and the enable state was lost without a word.
|
||||
`mkdirs()` used to create only components with a trailing slash, so `~/.config/udpscope` was never
|
||||
made and the *first* save failed silently -- worth knowing when a layout change "does not stick".
|
||||
- **The export is collected at click time, not in the file-dialog callback.** The live
|
||||
view keeps scrolling while the chooser is up, so a late collection would write a
|
||||
window the user never saw. The writer is picked by the chosen path's extension.
|
||||
- **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
|
||||
`run_ui()` need no notion of "no source yet". It is `--server` alone and not "no arguments":
|
||||
`--multicast` still needs a server to fetch CONFIG from. **Every documented probe passes
|
||||
`--server`**, which is what makes the feature invisible to the regression net — a probe added
|
||||
without it would reach the chooser and hang. `gtk_init()` plus a nested `GMainLoop`, and then
|
||||
`run_ui()`'s `GtkApplication` afterwards in the same process, is a verified combination
|
||||
(`gtk_init()` is idempotent; running your own loop is the documented GTK4 replacement for the
|
||||
removed `gtk_dialog_run()`).
|
||||
- **A session file's name cannot say how its source was reached, so the file says it.**
|
||||
`<host>_<port>.toml` carries neither the multicast group nor the interface, so a chooser row
|
||||
could not reconnect to a multicast source at all. `LayoutState::multicast`/`interface_addr` are
|
||||
written in the **top block, before the first `[[table]]`** — a bare key after a table header is
|
||||
that table's key in TOML, which is exactly how `enabled` was parsed into the last
|
||||
`[[calibration]]` record and lost without a word. A file from an older build has neither key and
|
||||
loads as unicast on the default route. The chooser's list filters to sessions that **name at
|
||||
least one signal**, because a probe run leaves a file per throwaway port and all of them hold
|
||||
`signals = ["", "", "", ""]`; `known_sources()` splits the filename at the **last** `_`, since
|
||||
`sanitize()` turns anything unsafe in a host into one.
|
||||
- **`save_layout_now()` refuses to write while `signals()` is empty.** A run against a source
|
||||
whose streamer was down receives no CONFIG, so every slot name stays empty and the save would
|
||||
overwrite a configured session with `signals = ["", ...]`. The chooser then filters that 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.
|
||||
@@ -0,0 +1,77 @@
|
||||
# Invariants: the time axis and the window control
|
||||
|
||||
What may change the time scale (one function, one caller), how the x labels pick their form, and what a fit restores.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **The window control's label is width-locked and ellipsized** (`kWindowLabelChars`), for
|
||||
the same reason as the status label: it sits in the header bar above the plot, so a label
|
||||
that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it
|
||||
on a mere change of window. That is also why the window is typed into a popover rather
|
||||
than shown in the bar.
|
||||
- **No plot interaction changes the time scale; only the window control does.** The *time scale*
|
||||
is more than the axis: `RowSet::window_s_` also feeds `ScopeModel::set_event_span()` (the
|
||||
trigger's pre-trigger share is a share of *this* window), `ScopeModel::set_target_span()`
|
||||
(which may re-plan the partition, move the decimation and **drop the history**), the window
|
||||
control's readout and the session file. A **zoom or a pan** does not touch it by construction —
|
||||
the controller writes the *member* axis and `clamp_to_data()` caps the span at `window_s_`, so
|
||||
nothing has to be written to keep it that way. **Neither do the two fits**: a fit is the
|
||||
*undo of a zoom*, so `RowSet::fit_x()` restores the window that is already set and reads
|
||||
`window_s_` without ever writing it. Both fits used to frame the *data* instead and move
|
||||
`window_s_` alone, which was wrong twice over — a gesture-triggered re-plan of the rings, and
|
||||
an event span left on the *old* window until the next decade step or unit change applied the
|
||||
fitted value for real (one action, two changes of time scale).
|
||||
- **`apply_time_window()` is the only place the time scale changes**, and the window control is
|
||||
its only caller. It clamps to `window_limit()` and moves `set_target_span()`,
|
||||
`set_event_span()`, `RowSet::set_time_window()`, the control and the session save together —
|
||||
the partition **before** the axis, so the axis is framed over the rings that will exist. A fit
|
||||
must not come here for two reasons: it is not entitled to the scale at all, and
|
||||
`set_time_window()` unpins and re-arms the live follow, which would throw a held event or a
|
||||
paused view onto a live edge it has nothing bound to. Paths that change the span for their own
|
||||
reasons (an event pin) call `sync_window_display()` alone, which only restates the control and
|
||||
never moves the plot — calling `apply_time_window()` there would clamp a legitimately short
|
||||
capture up to the requested window.
|
||||
- **The x labels go relative on the tick *step against the label resolution at the axis'
|
||||
magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can
|
||||
this step be written at this magnitude": `TickFormat::Time` carries three significant figures
|
||||
below a minute, **whole seconds** through `m:ss`/`h:mm:ss` up to a day, and three figures of
|
||||
days above — so the resolution is a step function of the magnitude, which `time_label_resolution()`
|
||||
states and nothing else may re-derive. The old test was `span < 1.0` and it was wrong in both
|
||||
directions: a 2 s window at twenty minutes of uptime reads `20:00` on every tick and stayed
|
||||
absolute, and a **triggered** sub-second window — whose axis is already `[-25ms, +25ms]` about
|
||||
the edge — went relative and so re-anchored its ticks *away from the trigger*, with a `t0`
|
||||
caption that had nothing to explain. Three details hold it up. The step is read back from the
|
||||
majors the **last layout** produced, which is not a feedback loop (a tick's value depends on the
|
||||
range and the axis' pixel width, never on the label form) but *is* absent before the first
|
||||
layout — and with no ticks the form must be **left alone**, because a step guessed from the span
|
||||
would be latched by the hysteresis below. The hysteresis is a 1.05 band on *leaving* the relative
|
||||
form. And *entering* it needs a ULP of slack (`1 - 1e-9`): a step differenced from two tick
|
||||
values and a resolution from `pow(10, n)` differ in their last bits when they are the same
|
||||
number, and `step == res` is a readable axis (`1.73s`, `1.74s`, `1.75s` — one in the last digit
|
||||
is exactly enough), so the bare comparison picked a different form from one frame to the next.
|
||||
The guard is `[timelabels-probe]`'s `distinct=1`, which asserts what the form is *for* rather
|
||||
than which form a given window gets.
|
||||
- **The `t0 = …` caption is chrome, never an annotation.** It is `Plot::set_x_note()` (added to
|
||||
`deps/plotter` for this), drawn by `Renderer::draw_x_note()` on the legend's own
|
||||
`draw_readout_panel()` backplate in the bottom-left corner of the axes, in **pixels**. It was an
|
||||
`Annotation` first, and an annotation's anchor is a *data* coordinate: the caption therefore
|
||||
crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move
|
||||
slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because
|
||||
the caption has to name the anchor precisely enough that label + caption is the exact absolute
|
||||
time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only the **bottom
|
||||
row** of the figure carries one, every other row being set to `""`; and the renderer measures it
|
||||
rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is
|
||||
no rect to read back, and a caption explaining the x labels must not be able to move the layout
|
||||
those labels were measured into. The guards are the library's
|
||||
`the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move` (the footprint is the pixels a frame
|
||||
with the caption differs from one without, and a pan plus a 64× zoom must give the same box to
|
||||
the pixel) and `[timelabels-probe]`'s `note=`, which reads `Plot::x_note()` back.
|
||||
- **`fit_x()` restores the *whole of what the view may show*, which is not always `window_s_`.**
|
||||
Live, that is the newest `window_s_` at the live edge (`set_time_window(window_s_)`, the same
|
||||
path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the
|
||||
**pin's** full range, which is the snapshot's own stamps and may be legitimately *narrower*
|
||||
than the requested window. It must write the per-row **member** axes as well as the link group:
|
||||
`hold_pinned_window()` prefers a member that differs from `pinned_stated_`, which is how it
|
||||
tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed
|
||||
range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so
|
||||
the two cannot drift apart.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Invariants: the trigger, the capture and a pinned view
|
||||
|
||||
The four display modes' framing rules: what the event window is counted in, what pins an axis, and what each pin is measured from.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||
|
||||
- **`clamp_to_data()` caps the span at the *requested window*, never at the retained
|
||||
span.** It runs every frame and cannot distinguish "the user zoomed out past the data"
|
||||
from "the ring has only just started filling", so `window_s_` — what the window control
|
||||
states, capped by `--max-window` — is the bound. Clamping to `retained` alone turned a
|
||||
requested 2 s window into the ~0.02 s the first frames held, permanently: nothing there
|
||||
grows a span back. Loosening it to `max(retained, window_s_)` fixed that and broke the
|
||||
other state instead — while the ring is filling the two are equal, but once it holds
|
||||
more than the window the bound becomes the whole ring, and a wheel zoom-out walked the
|
||||
view into data the window was never set to show. `window_s_` is right in both: it never
|
||||
shrinks a window a caller asked for, and a partly filled buffer is left empty on its
|
||||
left, which is what a scope that just started should look like. `UDPSCOPE_PROBE_PAN_MS`
|
||||
reports it as `zoom ... span=.. window=.. limited=1`.
|
||||
Its pan clamping is likewise gated on the data being wider than the view, or the empty
|
||||
left-hand side drags the live edge off the right-hand edge.
|
||||
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
|
||||
signal nobody acquires can never fire, so selecting/arming enables it.
|
||||
- **The trigger's event window is separate from the display window**, and
|
||||
`pretrigger_pct` is a share of the *event* span, not of the ring.
|
||||
- **The event window is counted in stream seconds end to end; nothing converts it to
|
||||
a sample count.** `TriggerEngine` latches once the stamps it is fed have advanced
|
||||
past the post window, and `ScopeModel::publish_event()` cuts the snapshot by binary
|
||||
search over the time ring. There used to be a measured samples-per-second estimate
|
||||
in `Acquisition` doing that conversion, and it is precisely why the post window came
|
||||
out empty: any stream whose time signal advances in large steps drove it to its
|
||||
1 S/s floor, the count came out as 1, and the engine latched on the edge sample
|
||||
itself. **Do not reintroduce a rate estimate on this path.**
|
||||
- **A pinned window is *clamped* into the event every frame, and a zoom in is allowed.**
|
||||
While an event is held, a capture is filling, or the view is paused, `tick()` calls
|
||||
`RowSet::hold_pinned_window()`, which caps the span at `[-pinned_pre_, +pinned_post_]`'s and
|
||||
clamps the position into it (and states `Off`, since a following group would re-derive from a
|
||||
live edge). **A zoom in must keep working** -- "not outside the event" is not "not readable",
|
||||
and this function's first version *re-stated* the pin every frame, which made the wheel a no-op
|
||||
in single trigger mode: an acquired event could not be examined at all.
|
||||
**The request is read from the row's own axis, not from the group.** A member's `set_x_range`
|
||||
-- what the controller writes for a gesture -- is *not* adopted into the group while the follow
|
||||
is off, so a clamp that read the group only ever saw its own previous answer (the probe showed
|
||||
`group` and `member` both equal to the pin while the ask was gone). `pinned_stated_` remembers
|
||||
what the clamp last wrote: a member that matches it is our own answer coming back, one that
|
||||
differs is a gesture to honour -- which is also what keeps a *fresh* pin
|
||||
(`pin_event_window()`, `pin_pause_window()`, both of which write the group in the same tick)
|
||||
from being reverted to the member's stale window. Without it nothing bounds a drag or
|
||||
a zoom in those states: the library clamps a *following* group's offset only, and the live
|
||||
clamp is deliberately skipped here — so a gesture walked the frozen view off its axis, over
|
||||
data the scope never had. The two are one branch in `tick()` for that reason. A paused view
|
||||
pins the same way (`pin_pause_window()` sets `pinned_` from the frozen range), so the frozen
|
||||
picture is equally immovable; resuming or leaving trigger mode clears it.
|
||||
- **A pinned view is navigable *within its data*, and that is the bound -- not the request.**
|
||||
Two things had to agree for this, and both were wrong in the same direction:
|
||||
the **pause** pinned on the window it asked for rather than on the snapshot it got (a cut
|
||||
clamped to the ring is shorter), and the **held event** pinned on the snapshot's recorded
|
||||
`pre_s`/`post_s` -- the *request* again -- which a capture shorter than the request exceeds.
|
||||
Either way the shortfall was drawn as an empty margin, and a zoom-in moved the view *into* it,
|
||||
which is what made it look as if zooming caused it. `tick()` now re-states the pin from
|
||||
`RowSet::event_data_window()` (the bound **event series'** own stamps) while an event is held,
|
||||
from the snapshot's stamps on a pause, and `pin_pause_window()`/the pause handler set the axis
|
||||
to it as well; `[pause-probe]` prints `axis=` beside `frozen_x=` and the axis must lie inside.
|
||||
A `Capturing` view is the exception and stays on the *requested* `[-pre, +post]`: its post side
|
||||
is empty on purpose, and that is the capture filling in view, not a margin.
|
||||
- **A pause is pinned from its snapshot's own stamps too, not from the window it requested.** The
|
||||
pause freezes the *visible window*, but `publish_pause_view()` cuts that window out of the ring
|
||||
and the cut is clamped to what the ring holds -- so a window reaching back past the oldest
|
||||
retained sample produces a *shorter* snapshot. Pinning the axis to the request left the shortfall
|
||||
as an **empty margin on the left**, visible before any zoom and made worse by one: zooming in
|
||||
moved the view *into* the empty part, which is what made it look as if zooming caused it. The pin
|
||||
is the snapshot's own stamps now. `[pause-probe]` prints `axis=` beside `frozen_x=`, and while
|
||||
paused **the axis must lie inside `frozen_x`** -- equal at the moment of the pause, a subset
|
||||
afterwards if the probe zoomed in. Before the fix the two disagreed outright:
|
||||
`axis=[-1.44..0.56]` over data that started at `0.0`. (The event pin already worked this way; the
|
||||
pause now agrees.)
|
||||
- **The event axis is pinned from the snapshot's own stamps, not from the request.** A
|
||||
capture may legitimately be shorter than asked for — the ring need not reach back a
|
||||
full pre-trigger share on the first latch after arming, so `snap=[-0.339..1.000]`
|
||||
for a ±1 s request is correct there rather than a defect.
|
||||
- **The frame that *frames* a snapshot is the one that consumes its `event_seq()`.** The
|
||||
tick reads `display_mode()` *before* it takes `mutex()`, while the producer publishes
|
||||
inside that mutex and lowers `trigger_pending` only at the end of its packet — so a tick
|
||||
can read `Capturing`, block on the lock through `publish_event()`, and reach the framing
|
||||
step with a new sequence number it is not bound to. (The other case is benign and was
|
||||
always handled: a publication and the *next* edge in one frame, where the capture has
|
||||
framed its own window.) Advancing `seen_event_seq` on such a frame dropped the pin
|
||||
permanently — the axis kept the capture's requested `[-pre, +post]` over a shorter
|
||||
snapshot and `event_held` stayed false, letting `clamp_to_data()` drag the frozen view —
|
||||
so the test is `seq != seen_event_seq && display == Held` and the pin stays owed until a
|
||||
frame is bound to the snapshot. Visible only at high rates, where the publish is long
|
||||
enough to lose the race often; `[disarm-probe]`'s `axis=` against `snap=` is the guard,
|
||||
which means **running the disarm probe at 10 kS/s alone does not check this.**
|
||||
- **A capture in progress is the one place the axis is pinned from the *request*.** There
|
||||
is no snapshot to measure yet, so `DisplayMode::Capturing` frames
|
||||
`[-(span - post_span_s(span)), +post_span_s(span)]` — computed with the producer's own
|
||||
`TriggerConfig::post_span_s()`, not a second expression that rounds differently, because
|
||||
the marker at x = 0 has to land where the coming cut will put it. The latch then re-pins
|
||||
from the snapshot, and the two agreeing is why the picture does not jump at the latch.
|
||||
- **`clamp_to_data()` must be skipped while `capturing` as well as while `event_held`.**
|
||||
A capture is live data on a pinned axis — the exact combination the clamp was written
|
||||
for and against: it would follow the live edge and drag the frozen window off the event.
|
||||
- **`publish_event()` cuts one absolute index range and uses it for the time ring and
|
||||
every value ring.** That is what makes `stamps` and each `values[c]` equal-length
|
||||
and index-aligned by construction instead of by two truncations that can disagree —
|
||||
`EventSeries::gather()` indexes both at the same `i`.
|
||||
- **`live_window_locked()` is the one live cut, shared by `publish_event()` and the
|
||||
export.** Both need the same absolute index range over the time ring and every value
|
||||
ring, or an export and a capture of the same window would hold different samples. Call
|
||||
it under `mutex()`.
|
||||
@@ -0,0 +1,576 @@
|
||||
# Probe readings: what each line asserts
|
||||
|
||||
The per-line reference for the self-check's output — what every probe and dump line means, the value it must read, and the defect a wrong reading names.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
Two lines of the self-check output are assertions in disguise:
|
||||
|
||||
- `layout over N frames: axes.x A..B, ...` — **`viewport.w` must collapse to a single
|
||||
value and `axes.x`, `axes.w` and `y-gutter` must *settle*.** `note_y_gutter()` is
|
||||
monotone (`plot_host.cpp`), its one-shot widest-label reservation can land a frame or
|
||||
two after the first measured frame, and the figure serves its floor out of the label
|
||||
strip (`y_width = max(measured, floor - y_title_w)`, `plot.cpp`) so the reservation
|
||||
converges over two layouts rather than one. A `y-gutter 88..104` dragging
|
||||
`axes.x 88..104`/`axes.w 707..782` with it is that settle, not a twitch — confirm it
|
||||
with the `changes` line below. A range that keeps moving is the "twitching axis" bug:
|
||||
the plot being re-measured every frame. **A range that keeps *climbing* to 200 is the
|
||||
other one**: `note_y_gutter()` is handed the gutter the row was laid out with, which
|
||||
already includes the floor imposed last frame, so slack added to it is added again every
|
||||
frame and the gutter ratchets to its own 200 px cap — `axes.x 88..200 / y-gutter 88..200
|
||||
/ axes.w changes: 17` on an 870 px viewport is what that reads as, and 17 changes looks
|
||||
like a settle. The reservation's own padding is the only slack there may be.
|
||||
`axes.w` also no longer reaches the viewport's right edge: `apply_gutters()` reserves an
|
||||
empty band there (~59 px of 870), so `viewport.w - axes.x - axes.w` is a *constant*, not
|
||||
a zero. **`x-desync` must read 0**: it counts frames
|
||||
in which any row's x range differed from row 0's by more than a pixel of data, i.e.
|
||||
frames the x link group did not reach. `x-ticks` is content, not layout, and
|
||||
legitimately varies by one as a live
|
||||
window slides over a round number. The companion `axes.w changes: N (last at frame F,
|
||||
side panel up=S)` line separates the two readings: `0`, or a handful near the start,
|
||||
is a settled layout; a count near the frame count is the twitch. Runs far longer than
|
||||
the documented probes have shown one ~320 px step — the side panel's width — that
|
||||
reverts; it is intermittent and unexplained, so read `changes` before calling a range
|
||||
the twitch bug.
|
||||
- `[trig-probe] ... trigger=on ... display=... epoch=...` (stderr,
|
||||
`UDPSCOPE_TRIG_TRACE_MS`) — with `trigger=on`, `display` must read `event(frozen)`,
|
||||
`live(capturing)` or `nothing`, **never plain `live`**, except for the single interval
|
||||
that contains the toggle itself — recognise it by `latches=0` *and* the next probe
|
||||
reading `nothing` or `live(capturing)` with a frozen `axis=`. Plain `live` with the
|
||||
trigger on is the old "the plot keeps scrolling in trigger mode" defect; the named
|
||||
`live(capturing)` is the feature. In that state `epoch=` must equal minus the capture's
|
||||
edge and `axis=` must stay at `[-pre..+post]` across consecutive probes while
|
||||
`data=`'s upper bound climbs towards `+post` — an axis that moves instead means the
|
||||
capture is being treated as a live view (`clamp_to_data()` reaching a pinned window).
|
||||
`epoch=` is **minus the newest acquired stamp** in `live` and `nothing` — it is what makes the
|
||||
live axis `[-window, 0]`, so there it grows (in magnitude) with the stream and a `0.0000` is
|
||||
the defect, not the healthy reading. It is back to `0.0000` only in `event(frozen)`, where the
|
||||
snapshot carries its own origin (`EventSnapshot::x_offset`) and the display epoch has nothing
|
||||
left to do. Note the probe reads
|
||||
`pending=` fresher than `display=` (which is last tick's decision), so a single
|
||||
`pending=0 display=live(capturing)` line is one frame of sampling skew, not a defect.
|
||||
And `snap=[a..b]` must equal `axis=[a..b]`, with `b` positive by roughly
|
||||
the post-trigger share: that is the "post window is empty" defect's observable.
|
||||
**`drawn=[a..b]` must equal `data=[a..b]`** in both modes: `data` is `x_at()` (what
|
||||
the crosshair reads) and `drawn` is `x0() + gather()` (what the renderer maps), so a
|
||||
series that breaks the "gather emits x relative to `x0()`" contract shows up here as
|
||||
the two disagreeing by exactly `x0()` — a trace drawn a whole pre-trigger window left
|
||||
of its axis, invisible once the view is narrower than the shift, while the crosshair
|
||||
keeps reporting correct values.
|
||||
- `[trig-probe/idx] n=.. bounds=[..] dx=.. dx_real=.. dx_ratio=R got=[a..b) want=[c..d)`
|
||||
(stderr, same probe) — **`got` must contain `want`** (`a <= c` and `b >= d`), and
|
||||
`dx_ratio` must stay within about a percent of `1.0`. `got` is the index window
|
||||
`Renderer::draw_traces` gathers *today* — `neighbours()`, one sample of slack either side —
|
||||
and `want` is the same window from the same call, so read this line for the **rate**:
|
||||
`dx_ratio` is the nominal `dx()` against the real last-spacing, and it is the number that
|
||||
used to matter. It above 1 (every index scaled up, `a` past the first visible sample, the
|
||||
samples in between in the buffer and readable by the crosshair but never drawn) was the
|
||||
live view whose older part went blank while its newest part looked fine. That is a *fixed*
|
||||
defect: the renderer no longer walks the grid at all, because on a ring-backed series it
|
||||
also lost the two end segments and left the left and right margins of a zoomed-in view
|
||||
empty. The guard for it is the library's own pixel test
|
||||
(`a_sparse_window_between_samples_still_inks_both_margins`), not this line — a probe that
|
||||
recomputes the renderer's formula cannot see a renderer that changed it. Also
|
||||
read `axis=` in the companion line against the `window control:` line in the dump —
|
||||
a live axis narrower than the requested window is the same symptom from the other
|
||||
cause (see the `clamp_to_data()` invariant in
|
||||
[invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)). Both need a *saturated* ring to
|
||||
show up, so run them under `--buffer-mb 1`, which wraps in a few seconds.
|
||||
- `trigger: edge at X ... want [-a..b] got [-c..d] n=N` (stderr) — `got` must equal
|
||||
`want` and `N` must be exactly the window times the stream's real rate, plus one
|
||||
(2 s at 10 kS/s is `n=20001`, not 19999). `got` shorter than `want` on the *left*
|
||||
means the edge was accepted before the history reached back a full pre window (the
|
||||
gate in `TriggerEngine::process()`); shorter on the *right* means the engine latched
|
||||
early or the cut lost the newest samples. An `N` one or two short of the exact count
|
||||
is the same class of bug, not rounding: `publish_event()` clamps the slice to every
|
||||
value ring's `produced()`, so publishing before each channel of the latching sample
|
||||
has been pushed silently drops it.
|
||||
- `[disarm-probe] was_capturing=1 display=.. seq=A->B events=C->D snap=[a..b] n=N
|
||||
axis=[..]` (stderr, `UDPSCOPE_PROBE_DISARM_MS`) — with `was_capturing=1`, **`display`
|
||||
must read `event(frozen)`** and `snap`/`axis` must be `[-pre..e]` where `e` is how far
|
||||
the post window had filled, with `n` that span times the stream's real rate plus one.
|
||||
`display=nothing` (or a `snap` from the *previous* event) is the "disarming mid-capture
|
||||
makes the event completely disappear" defect. **`seq` must advance by exactly one while
|
||||
`events` does not move**: no event fired, so the partial publish deliberately leaves
|
||||
`trigger_latches()` alone — which is also what keeps the `trigger events: N total, M at
|
||||
the disarm` line in the dump meaning what it says.
|
||||
- `[export-probe] mode=.. axis=[a..b] rows=N cols=M time=[c..d] abs=[..]` (stderr,
|
||||
`UDPSCOPE_EXPORT_MS`) — **`time=` must equal `axis=`** and **`rows=` must be the
|
||||
span times the stream's real rate, plus one** (2 s at 10 kS/s is 20001). The export
|
||||
is the *visible* range at the *stored* rate, so a count far short of that is a table
|
||||
cut from the LOD pyramid, and a `time=` narrower than `axis=` is a window searched on
|
||||
the wrong clock (the live branch puts the window back on the stream's clock with
|
||||
`stream_at_x0()` before searching the ring, and that offset is a whole window while
|
||||
streaming — not the 0 it once was).
|
||||
**`abs - time` must be a single constant across both ends of a line**, and that constant is
|
||||
where the display's origin sits in the stream's own clock: `epoch()` plus the newest stamp in
|
||||
`mode=live`, `epoch()` plus the accepted edge in `mode=event`. That is the one assertion on
|
||||
the second column, and it replaces the old "`stream=` must be the edge ± the window" reading,
|
||||
which only ever held because the display clock *was* the stream clock. A `mode=live` line
|
||||
whose `abs - time` differs between the two ends is the two columns computed from different
|
||||
epochs — the defect `abs_base` exists as a single `const` to prevent.
|
||||
An export taken **mid-capture** reports `mode=live` with `time=[-pre..e]` where `e` is how far
|
||||
the post window has filled — the export is what is on screen, and during a capture that is a
|
||||
partly filled window. Pin the probe outside a capture when checking the `rows=` count.
|
||||
The two `bytes=` lines are the writers: the `.npy` is
|
||||
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
|
||||
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
|
||||
either writer.
|
||||
- `[window-probe] <action> label=.. entry=.. unit=U window=W event_span=E cap=C decim=M
|
||||
stored=R aa=F buffered=B` (stderr, `UDPSCOPE_PROBE_WINDOW_MS`) — **`window` must equal
|
||||
`event_span`** on every line (the trigger's event span is the display window; a capture
|
||||
cut to a different length than the axis frames is the "post window is empty" defect's
|
||||
cousin) and **neither may exceed `cap`**. The `unit ms->s` line is the one that catches
|
||||
the feedback loop: changing the unit re-interprets the typed number, so "500" in ms
|
||||
becomes 500 **s exactly**, and a `window=499.98` there means the control re-parsed its own
|
||||
rounded readout (`8.333 min × 60`) instead of the request — see the `window_sync_unit`
|
||||
invariant in [invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md). `label` is a reading to three decimals in a natural unit and is
|
||||
*expected* to be lossy (500 s reads `8.333 min`); it is the `window=` figure that has to
|
||||
be exact.
|
||||
For the resampling: **`buffered` must be ≥ `window`** on every line — that is what the
|
||||
decimation is for, and a `buffered` short of the window is the old "the control is capped
|
||||
to what the ring holds" behaviour coming back. `decim` must be a power of two,
|
||||
`stored × decim` must equal the wire rate, and `aa` must be exactly `stored / 2`. Run it
|
||||
under `--buffer-mb 1`, which saturates in a few seconds and makes windows past ~6 s
|
||||
decimate; at 200 MB nothing inside `--max-window` needs to.
|
||||
- **The anti-alias check** is the same probe run against a waveform above the stored
|
||||
Nyquist: `--buffer-mb 1 --freq 2000` with the probe ending on a 60 s window gives
|
||||
`decim=16`, a 625 S/s stored rate and a 312.5 Hz corner, and the channel dump's
|
||||
`ch 0 'sine' ... y=[-0.059, 0.079]` is the assertion — a boxcar of 16 at 10 kS/s passes
|
||||
2 kHz at about 6%. **A `y=[-1, 1]` there means the filter is gone** and the 2 kHz sine has
|
||||
aliased to a full-amplitude 125 Hz one, which looks entirely plausible on screen. This is
|
||||
the only check that distinguishes averaging from striding.
|
||||
- `[cursors-probe] gestureless plot move with no gesture: stored A -> B ignored=D` — a
|
||||
plot-level `set_cursor_x()` with **no gesture behind it must be ignored** (`ignored=1`, and
|
||||
`A == B`): the tick reads a row only while its controller holds a cursor, and this line is the
|
||||
guard for the defect where it adopted its own echo and a dragged cursor sprang back. (The
|
||||
*adoption* half -- a gesture that does move a cursor, and the shared value reaching every row
|
||||
-- is asserted in the `check_host` harness, which can call `sync_cursors(row)` directly; a
|
||||
synthetic press here cannot reach the adapter's own event pump.)
|
||||
- `[cursors-probe] <action> mode=.. on=D pane=D t0=.. t1=.. dt=.. T=[..] lines=N [..]`
|
||||
(stderr, `UDPSCOPE_PROBE_CURSORS_MS`) — walks `vertical` → `horizontal` → `both` →
|
||||
`mix`, then moves a marker *through the plot* (`Plot::set_cursor_x(..., Free)`, which is
|
||||
what a drag does) and reports again. **The times must survive every mode change**: they
|
||||
are the `RowSet`'s one pair, and a mode that parks the markers must not read them back
|
||||
(a parked cursor reports a real number, so a read-back would adopt it as a gesture — the
|
||||
bug that made `T0` read 0). **`lines`** must be 0 for `vertical`, one per row for
|
||||
`horizontal`/`both` (and the time row exists only in the modes that show the vertical
|
||||
pair — `T=[]` in `horizontal` is the assertion that it does not), and one per **bound**
|
||||
trace for `mix` — a slot with a name *and* a channel the model built, never a name alone
|
||||
(see the bound-slot invariant in
|
||||
[invariants-plot-and-layout.md](invariants-plot-and-layout.md); `lines=6 marks=3` over three real traces is that
|
||||
defect). Each line reports as `<name>=<A> <B> <dY>`, so the names are the reading: a row
|
||||
claiming a signal that is not on a plot is the phantom. In `mix` the values must be the
|
||||
traces' own (`--` when a marker is past the newest sample, `partial` when only one
|
||||
marker is over samples). After the move, `t0` must have changed and every row's marker
|
||||
must have followed it (the report's `t0` is the shared value). **`marks`** is the `mix`
|
||||
mode's crossing annotations: **0 in every other mode, and one per bound trace for each
|
||||
marker that is over samples in `mix`** (so a move that brings the second marker into the
|
||||
data doubles it). The `first mark at x=.. y=..` line that follows must agree with the
|
||||
pane's *first* value for the same trace (the pane's lines are `chip name Y1 Y2 dY` --
|
||||
the chip carries the signal's name, since a chip alone leaves the user to match a colour
|
||||
against the plot by eye -- with the values formatted as measurements, two decimals, an SI
|
||||
prefix, the channel's unit, so the comparison is against the first number): both are read through the same
|
||||
`Plot::crosshair_value()`, and that line is the check that they stay the same number.
|
||||
A **ruler** drag is the other direction and the other hazard: the pane's `A`/`B` cells
|
||||
for a ruler row must change when the ruler moves (`after ruler drag` in the probe walks
|
||||
the first ruler to 80% of its y range), because nothing shares a ruler and the read-back
|
||||
in `RowSet::sync_cursors()` is the only thing that can notice. The dump's `cursors in plot
|
||||
0:` line is the other half: it reads the markers back out of
|
||||
the plot (`cursor(id).x/.visible`), which is what the renderer draws from — the pane's
|
||||
numbers and the plot's state agreeing is the difference between "the app placed them" and
|
||||
"the app placed them and they are on screen".
|
||||
- `[measures-probe]` + the dump's `measure N: 'sig' type thr=..% above=D show=D -> ok|invalid value text=[..]
|
||||
[duty=..] [marks=..]` and `measures pane: n=N revealed=D marks_shown=M pane_rows=R pane_w=W
|
||||
pane_h=H`
|
||||
(stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first
|
||||
plottable signal and lets the tick compute them, so what it prints is what a user reads.
|
||||
On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and
|
||||
`bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s
|
||||
with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. It needs **`UDPSCOPE_ENABLE_ALL_MS`
|
||||
ahead of it** or it prints `no acquired signal to measure` and the dump reads `n=0`.
|
||||
**Run it against `--noise 0.1 --freq 1` as well, and the three crossing types must still read
|
||||
those figures** (measured: `period=1.000100 frequency=0.999900 pulse duration=0.504100`): the
|
||||
hysteresis band is the only thing holding them there, a single level reads a 500 Hz interferer as
|
||||
the signal, and the default `--noise 0` mock cannot see it. The band costs `raw`, not accuracy --
|
||||
the clean fast mock reads `raw=0.1%` at 1.41 ms a recompute and the noisy one `raw=2.0%` at 1.96
|
||||
ms, so read `measure cost:` on **both**. `marks=` lists each mark with its kind, and the **mix is the point**:
|
||||
`level:V` alone for average/top/bottom, `level:V,time:T` for min/max (a ruler at the value
|
||||
*and* a marker at the first sample that reached it — two level marks at both extremes was
|
||||
the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with
|
||||
its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration`
|
||||
seconds, and the rest the channel's unit. `marks_shown` counts two per shown measurement
|
||||
(a min/max showing both its marks counts two of its own). `measures window:` is the
|
||||
timeliness assertion -- the window the numbers were computed over against the one on screen.
|
||||
**The reading that must hold is `marks_in_window=K/K`**: every *time* mark inside the window
|
||||
on screen. Both ranges are on the **stream's** clock, which is the space `measure_window` and
|
||||
the time marks are stored in -- a live axis is a fixed `[-window, 0]`, so comparing display x
|
||||
would read no drift whatever the data did.
|
||||
`drift` itself is a **single sample of a sawtooth** and is not an assertion on its
|
||||
own: a live view scrolls between recomputes, so it rises to one `kMeasureRefreshUs` of scroll
|
||||
and drops to zero at each pass. The figure is `(|Δmin| + |Δmax|) / span`, so a pure scroll
|
||||
counts **twice** and the top of the sawtooth is `2 × refresh / window`: ~10 % on a 2 s window
|
||||
at the 100 ms cadence, and a few *hundred* percent on a window shorter than the interval (a
|
||||
50 ms window reads 254 % for 63.6 ms of scroll, and its ceiling is 400 %). Compute the ceiling
|
||||
for the window the run actually used before calling a figure large. A `0.0000%`
|
||||
is a dump that landed just after a pass, not a better state. Read it against the recompute count:
|
||||
*more* recomputes with a *higher* drift sample is the sawtooth, while a drift that exceeds one
|
||||
refresh interval of scroll, or marks outside the window, is the defect. A measurement is
|
||||
recomputed when the window moves (2 % of its span) or the held/live state changes, never on a
|
||||
wall-clock timer: a timer is what let a marker sit outside the viewport and a dragged one
|
||||
survive a frame. **`marks_selectable` must be 0**: the
|
||||
measurement marks are created unselectable (`Plot::set_cursor_selectable()`, which the
|
||||
library gained for this), read back out of the plot here -- re-placing a marker every frame
|
||||
does **not** stop a drag, because the controller holds it for the gesture's life and the
|
||||
marker follows the pointer between frames. The library's own controller test
|
||||
(`an_unselectable_cursor_is_neither_hovered_nor_grabbed`) is the other half. The probe's last
|
||||
step still drags a mark *programmatically* and requires the next frame to put it back
|
||||
(`restored=1`), which now checks the placement path rather than the read-only property. The dialog report's `type= thr=
|
||||
side= show=` is the per-type behaviour: `period` and `frequency` must show **neither** the
|
||||
split nor the side row (they compute from the mid level's rising crossings, and an option
|
||||
there is one the user can only get wrong), `average`/`min`/`max` no split either, and only
|
||||
`pulse duration` a side. A measurement whose signal is not acquired, or
|
||||
a window with too few crossings, must read `invalid`/`--` rather than a made-up number
|
||||
(`frequency` on a window holding one edge is the case: `duty` is reported only when a
|
||||
period exists to divide by). The probe then **walks the dialog's lifecycle**: open the edit
|
||||
dialog on measurement 0, click its `Save`, open it again, click `Delete` — `measures=` must
|
||||
be unchanged after the save and one lower after the delete, with the process still alive.
|
||||
That is the check for the two things a dialog of this shape gets wrong: two buttons owning
|
||||
one context (a double free aborts on edit/delete, and only those paths have two buttons) and
|
||||
a `const char*` array built from a `std::vector<std::string>` as it grew (every pointer
|
||||
dangling, the first entry the most visibly). The report's `signals=[..]` and `drop_h` are the second and third:
|
||||
the list must be the **plotted traces** (real names, deduplicated, in signal-list order —
|
||||
*not* every channel, and never a stream's timestamp signal, which is what an acquired-but-
|
||||
unplotted list put first and auto-selected), and `drop_h` must be a combo box's height
|
||||
(tens of pixels — a scrolled-window wrapper asked it to grow to 160).
|
||||
- `[timelabels-probe] <action> relative=D distinct=D window=.. origin=.. note=[..] ticks=[..]
|
||||
first_value=.. first_rel=.. range=[..]` + the dump's `time labels: relative=D distinct=D
|
||||
origin=.. note=[..] x_origin_set=.. ticks=[..] first_rel=..` (stderr,
|
||||
`UDPSCOPE_PROBE_TIMELABELS_MS`) — the x label form across a zoom, driven through
|
||||
`apply_time_window()`: **under the figure's x link group the group owns the window, so a
|
||||
row-level `set_time_window()` does nothing** (a probe that changes the window must use the
|
||||
app's path). **`distinct` must be 1 on every line, and that is the assertion** — it says no two
|
||||
major labels on the axis are the same string, which is the one thing the relative form exists to
|
||||
buy and the only reading that holds whatever the stream's uptime is. `relative=D` is **expected
|
||||
to read 0 on every line now**, and that is a consequence of the newest-relative axis rather than
|
||||
a change to the rule: the form is decided by the tick step against the label resolution at the
|
||||
axis' **magnitude** (see
|
||||
[invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)), and no display mode carries a large magnitude any
|
||||
more — live and waiting are `[-window, 0]`, a capture and a held event are edge-relative, a pause
|
||||
is freeze-relative. The 50 ms window that used to read absolute at 2 s of uptime (`2.13s, 2.14s,
|
||||
2.15s`) and relative at 17 s (`100µs, 10.1ms, 20.1ms`, with the caption) now reads
|
||||
`-40ms, -30ms, -20ms, -10ms` at any uptime. The rule and the caption are kept because they are
|
||||
the answer for *any* axis magnitude and nothing in them is mode-specific — do not delete them on
|
||||
the strength of this reading — but what the probe asserts is `distinct`, not the form. The
|
||||
**caption must be non-empty exactly when `relative=1`**, which here means empty throughout. Run it after
|
||||
`UDPSCOPE_ARM_TRIGGER_MS` as well, which is the case the span rule got wrong: a triggered 50 ms
|
||||
window must read `relative=0 note=[] ticks=[-20ms,-10ms,0,10ms]` — a `t0` caption on an axis
|
||||
that is already edge-relative re-anchors the ticks away from the trigger, which is the one
|
||||
position the axis exists to show. The probe *races the paint* — it reads the axis on a timer,
|
||||
and the ticks it sees are the previous layout's — so its `first_rel` (the first major minus
|
||||
the axis' origin) is a fraction of a step; the dump runs after the last paint and is the
|
||||
race-free reading. Either way the assertion that matters is the *sum*: `first_value` must
|
||||
equal `origin + first_rel` as a *tick label* plus its caption, i.e. the labels and the
|
||||
caption must share one anchor. `x_origin_set` is read back out of the plot; it must be 0 in
|
||||
the absolute form. Note the last two steps both report `window=0.05`: `RowSet::set_time_window()`
|
||||
floors the window there, so the 20 ms step is clamped.
|
||||
- `[fit-probe] <action> pinned=D window=.. event_span=.. x=[..] span=.. y=[..]` (stderr,
|
||||
`UDPSCOPE_PROBE_FIT_MS`) — the two fits, through the **menu handlers** rather than the bare
|
||||
`RowSet` calls, because the notice and the control are the handler's half. A fit is the **undo
|
||||
of an x zoom**, so the probe performs one: it moves the y scale off the data (a fixed
|
||||
`[-100, 100]`), sets a 2 s window through the control's own path, then zooms row 0 to a quarter
|
||||
of it the way a gesture does — on the *member* axis — and fits. Four assertions.
|
||||
**`window` and `event_span` must be unchanged on every line** and equal to the typed 2 s: a fit
|
||||
is a plot interaction and may not restate the time scale (see
|
||||
[invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)), and they are
|
||||
the only two readings that can see it try. The `zoomed` line's `span` must be a quarter of the
|
||||
window, or the fits have nothing to undo and the lines below mean nothing. **`fit x` must put
|
||||
`span` back to the whole window and leave `y` exactly as it was**, and **`fit all` must do the
|
||||
same and move `y` to the data's own range** — a `y` that changed on the `fit x` line is the two
|
||||
paths aliased. `x`'s *position* advances between lines on a live view and is not an assertion:
|
||||
the follow is re-armed on the newest data, which is what the whole window means there.
|
||||
Run it **inside a pause** as well (`UDPSCOPE_PROBE_PAUSE_MS=2000 UDPSCOPE_PROBE_FIT_MS=2200`),
|
||||
which is the other branch and the one that can silently revert: `pinned=1`, and `fit x` must
|
||||
restore the *pin's* full range — the snapshot's own stamps, legitimately narrower than the
|
||||
requested window (`span=1.179900` against `window=2.000000` is correct, not a finding) — and
|
||||
must still read `pinned=1` afterwards. The pin is restored by writing the **members** as well as
|
||||
the group, because `hold_pinned_window()` prefers a member that has moved since `pinned_stated_`
|
||||
— that is how it tells a gesture from its own echo — so a fit that wrote the group alone reads
|
||||
back as the zoomed range one frame later.
|
||||
- `[layout-probe] <action> rows=N buttons=B done=D done_box=[x,y,WxH] rows_y=[..] close=[..] add=[..] traces=[..]`
|
||||
(stderr, `UDPSCOPE_PROBE_LAYOUT_MS`) — **`buttons` must be `2N+1`** while the editor is
|
||||
up (one `×` per row, one `+` per boundary) and **0 on the `editor off` line**: the
|
||||
buttons exist only while the editor does. `done` follows the same rule for the editor's
|
||||
own exit button (1 up, 0 off), and its `done_box` on the `placed` line must sit in the
|
||||
area's top-right corner: `x + W` within a few pixels of the area width and `y` within a
|
||||
few of 0 (the on-switch line reports a box from before the first frame, so read the
|
||||
`placed` one). The last probe step leaves the editor by **clicking that button** — the
|
||||
toolbar toggle is still exercised by the first step — so `buttons=0 done=0` with the
|
||||
rows and traces untouched is the assertion that the way out works. **`traces=` is the real assertion**: an insert
|
||||
or a remove moves the rows' *contents* with them, so `add above 0` must read
|
||||
`[0:-|1:sine,…]` — row 0's signals now on row 1, not renumbered onto the new empty row —
|
||||
and `remove 0` must pull them back to `0:`. A `traces=` where the signals stayed on row 0
|
||||
means `carry_row()` was skipped and the rows were renumbered under the user.
|
||||
Geometrically, each `close=` must equal its row's top from `rows_y` plus the pad, and
|
||||
`add=` must be centred on the boundaries (so strictly increasing, the first at 0 and the
|
||||
last clamped to the area's bottom). The `rows_y` spans must tile: a gap other than
|
||||
`kRowGap` means the figure and the overlay disagree about where a row is, which is how a
|
||||
`×` ends up on the wrong row. Also read `x-desync 0` in the dump — every row the editor
|
||||
adds has to join the one x link group. Run it at `--plots 8` as well: the cap and the
|
||||
floor are the two refusals, and because the probe emits `::clicked` straight at the
|
||||
widget it reaches the handler even when the button is insensitive, so `add above 0` there
|
||||
must leave `rows=` and `traces=` untouched rather than growing a ninth row.
|
||||
- `header: window_button_w=W label_w=L bar_w=B` (stderr, once a second) — **`W` must be
|
||||
a small constant and must not track `B`**: the window control is a header-bar child,
|
||||
and an expanding one is measured into the title's centre box, so it stretched from the
|
||||
label to the title (516 px at a 1270 px bar before the fix, 121 px after). A `W` that
|
||||
grows with the window width is that bug back.
|
||||
- `toolbar icons: trigger=NAME(ok) pause=... cursors=... measures=... math=... sidebar=...
|
||||
menu=...` (in the exit dump, beside `panel controls:`) — the icon name every toolbar button
|
||||
carries, with `gtk_icon_theme_has_icon()`'s verdict. **Every entry must read `(ok)`.** Four
|
||||
of the names are this app's own (`udpscope-trigger-symbolic` and friends, a `GResource`
|
||||
compiled into the binary — see the toolbar-icon invariant in
|
||||
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)), and a `(MISSING)` on one of
|
||||
those means the resource path was never registered. That failure is otherwise *silent*: GTK
|
||||
substitutes a missing-image placeholder, the button still works, and every other line of the
|
||||
dump still passes. Changing an icon name is a one-line change in `build_toolbar()` and this
|
||||
is the only check that it names something that exists.
|
||||
- `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump,
|
||||
beside `toolbar icons:`) — the application icon, named and present at the size its own root
|
||||
`<svg>` element declares. **Both entries must read `ok` at those sizes.** `ok` is
|
||||
`gtk_icon_theme_has_icon()` *and* the resource bytes being found under the registered path, so a
|
||||
`MISSING` is a resource path that was never registered or a file that was renamed, and a size
|
||||
other than 128 or 16 is a canvas that drifted off the HIG's. **Read it together with
|
||||
`toolbar icons:`, and trust that line further for the registration**: the resource-path
|
||||
registration is one call in `run_ui()` serving both the window's icon and the toolbar's buttons,
|
||||
so a `MISSING` on either line is the same defect — but on a machine where
|
||||
`--target desktop-entry` has been run, *this* line's `has_icon` half is also satisfied by the
|
||||
**installed** copy under `$XDG_DATA_HOME/icons/hicolor`, so a broken registration still reads
|
||||
`ok` here. That was measured: with the resource path deliberately misspelt, `toolbar icons:`
|
||||
reported four `MISSING` (nothing installs the action icons) while `app icons:` stayed `ok`
|
||||
until the two installed SVGs were moved aside, whereupon it read `MISSING 128x128` /
|
||||
`MISSING 16x16`. The line is still worth its place — the *present* half (the resource bytes) and
|
||||
the declared sizes are read from the binary and nothing else can satisfy them — but a developer
|
||||
machine is the one where it is least sensitive.
|
||||
**It deliberately does not claim the icon renders**, because no runtime GTK call can:
|
||||
`gtk_icon_theme_lookup_icon()` never returns `NULL`, its paintable's intrinsic size is the size
|
||||
it was *asked* for, and a valid icon, a malformed one and a name that does not exist at all all
|
||||
snapshot to the same `GSK_TEXTURE_NODE` of the requested bounds — GTK substitutes
|
||||
`image-missing`. A malformed SVG is a *build* error instead; see the icon-authoring rules.
|
||||
- `[sources] N known: HOST:PORT plots=P signals=S [multicast=G] [interface=I] | ...` (stderr,
|
||||
`UDPSCOPE_PROBE_SOURCES=1`) — the startup chooser's discovery, printed and exited **before
|
||||
`gtk_init()`**, so this one needs no display and no `xvfb-run`. **Only sources with a named
|
||||
signal may appear**: a development machine accumulates one session file per probe run on a
|
||||
throwaway port (140 of them when this was written), and the filter in `known_sources()` is
|
||||
what keeps the list the sources a user configured. Order is newest first by file mtime.
|
||||
- `[sources] picked HOST:PORT multicast=G interface=I` (stderr, `UDPSCOPE_PROBE_PICK_MS`) —
|
||||
shows the chooser, then activates row 0 the way a click does and continues into the real
|
||||
application, so a full run with **no `--server`** must reach the scope and emit every other
|
||||
dump line unchanged. `multicast=`/`interface=` coming back non-empty from a stored row is the
|
||||
assertion that a multicast session reconnects to its group, and it needs **no multicast
|
||||
networking**: write the session file by hand and read the line. With
|
||||
`UDPSCOPE_PROBE_NEWHOST=host:port` it drives the form instead — `[sources] form open,
|
||||
connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a
|
||||
dialog built and destroyed as it is a state check. An empty list must print `picked nothing:
|
||||
the list is empty` rather than dereferencing a null row.
|
||||
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
|
||||
with a drop and a window change, then again with no interaction: the second run's dump
|
||||
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
|
||||
first run's values, and the signals it names must come back *acquired* (a `row 0 slot 0
|
||||
-> channel ... samples=N` line). Delete the file before a run whose layout you did not
|
||||
mean to inherit (`127.0.0.1_9540.toml` and friends accumulate otherwise).
|
||||
- `[udpscope] starved: connected (config=1), reconnects=N, no new frames in the last
|
||||
second ...` (stderr, once per second while the state holds, plus a status-line notice) —
|
||||
"connected but nothing is arriving after a reconnect", which over unicast is the second
|
||||
instance taking the source's single session. Check it does **not** fire against a live
|
||||
source. The status line carries ` | N reconnects` only when N > 0, which is the same
|
||||
counter without the prose.
|
||||
**The same stall test has two outcomes, split on `have_config`, and they are different
|
||||
diagnoses.** With a signal list in hand, the above. With **no CONFIG at all** it prints
|
||||
`[udpscope] connecting: connected (config=0), reconnects=N, no configuration from the
|
||||
stream ...` and the notice reads *trying to connect* — nothing was taken away, because
|
||||
with no signal list nothing can be enabled and no DATA frame is owed, and the old wording
|
||||
blamed a second reader that does not exist. That state is also named *without* waiting for
|
||||
the three-sample stall: the glance word is `connecting` and the badge is **yellow** from the
|
||||
first frame (see `badge frames:`). The way to see it with no second client is to point the
|
||||
app at a UDP port where nothing answers (the client CONNECTs, times out, reconnects), which
|
||||
is now the `config=0` branch and no longer exercises the `starved:` one at all — a check of
|
||||
the unicast-contention wording needs a real second reader, or a source that configures and
|
||||
then goes quiet.
|
||||
- **The export's `time`/`abs_seconds` columns must be strictly increasing and evenly
|
||||
spaced.** Check the written file, not just the probe: `np.diff(t)` with no `<= 0` and
|
||||
`rows / (t[-1] - t[0])` equal to the stored rate (the `ch` line's figure). Duplicate or
|
||||
backwards stamps mean the recorded timeline holds two clocks or a collapsed cadence —
|
||||
the two ways this has broken: a per-frame choice of time source (now sticky, see the
|
||||
README) and a per-sample step derived from the client's `lost` counter (now the
|
||||
stream's measured arrival cadence). A `rows=` count far below the span times the rate
|
||||
is the other signature, that of a table cut from the LOD pyramid.
|
||||
**The step must hold to a few ULP of the column's own magnitude**, and this is the check that
|
||||
needs a long run at a high rate: the old form of this assertion was "`set(np.diff(t))` holds
|
||||
exactly one value", and that was only true while `time` *was* the stored stamp. Both columns
|
||||
are derived now — `time` is `stamp - stream_at_x0()` and `abs_seconds` is that plus a base of a
|
||||
wholly different magnitude — so each subtraction costs a rounding and `set()` legitimately
|
||||
holds a handful of neighbouring doubles. Measured: four values spanning ~1.5 ULP, 6.8e-16 s.
|
||||
The thing that must not come back is the **plateau**: equal stamps, or a diff taking two or
|
||||
three values a few *hundred nanoseconds* apart, is the time ring losing precision with uptime
|
||||
(see the `f64` time-ring invariant in
|
||||
[invariants-data-path.md](invariants-data-path.md)). The figure to compute is the ULP of `t[0]` and of
|
||||
`abs_seconds[0]` — the defect that invariant guards is microseconds, nine orders of magnitude
|
||||
above the arithmetic noise, so the two readings cannot be confused.
|
||||
- `badge frames: lit=L dim=D idle=I connecting=C` — the activity badge's phase per frame. Over
|
||||
a run that acquired anything **both `lit` and `dim` must be non-zero**: that is what "it
|
||||
blinks" means, and a badge stuck lit is indistinguishable from a working one in a
|
||||
screenshot. One or two `idle` frames at the start are the frames before the socket
|
||||
connected; a large `idle` is correct only when the run paused or disarmed (a
|
||||
`UDPSCOPE_PROBE_DISARM_MS` run should show hundreds). **`connecting` must be 0 on a run
|
||||
against a live source** — it is the steady *yellow* phase, "the socket answered but the
|
||||
stream has sent no CONFIG", and a non-zero count against a working mock means a signal list
|
||||
that arrived late or not at all. It is the *only* non-zero phase on a dead-port run
|
||||
(measured: `lit=0 dim=0 idle=90 connecting=446`, the `idle` being the frames before the
|
||||
first CONNECT), which is also the check that a source that has sent nothing never gets the
|
||||
blinking green. The `mode ...` lines carry the rest of the status line, so `rate` and
|
||||
`last event ... ago` are checked there.
|
||||
- `frame cost: tick A/B ms over N, draw C/D ms (max at frame F), paint E/G ms over M` and
|
||||
`frame path: raw=R lod=L columns=C graded=G frames, segments S/T (mean/max), visible max V samples`
|
||||
— the
|
||||
drawing cost, split into the frame tick, `PlotArea`'s own draw (timed from the frame
|
||||
clock's `before-paint` to the end of the host's `render` handler) and the whole GTK
|
||||
paint. **`segments` max must stay in the low thousands and `lod` must dominate `raw`
|
||||
on any run holding a large capture**: a max in the *millions* with `lod=0` is the
|
||||
empty-pyramid defect (see the `MinMaxPyramid` invariant in
|
||||
[invariants-rendering.md](invariants-rendering.md)) — the renderer drawing
|
||||
every raw sample of a held event on every frame, which measured 6,000,073 segments and
|
||||
100–230 ms a frame at 1 MS/s. `raw` frames are expected and cheap below 64 samples per
|
||||
pixel, which is where `level_for()`'s own gate puts the boundary.
|
||||
**`columns` must equal the frames that drew a trace at all on any run at a high rate**, and
|
||||
it is the reading that says *why* `segments` is small: a dense gathered window is reduced
|
||||
onto the pixel grid upstream (one snapped vertical segment per column — see the moiré
|
||||
invariant in [invariants-rendering.md](invariants-rendering.md)), so the geometry is bounded by
|
||||
`axes.w`, and a `columns=0` beside a large
|
||||
`segments` is that reduction gone rather than a narrower window. Measured on
|
||||
`--rate 1300000 --batch 2000 --freq 3000` over 20 s: `raw=2 lod=1198 columns=1200 frames,
|
||||
segments 2041/2237` over 1201 painted frames, against `columns=0 ... segments
|
||||
10960/216093` over 263 before it existed — the same run at 4.6× the frame rate.
|
||||
**`graded` must equal `columns`**: this app turns `Style::intensity_grading` on, and grading is
|
||||
a way of *drawing* the reduction rather than a third path, so a `graded` below `columns` is the
|
||||
style not reaching a frame. **`UDPSCOPE_NO_PHOSPHOR=1` is the A/B** and the only way to read
|
||||
either of these two lines against the ungraded path, since a frame mean cannot attribute its own
|
||||
cost: measured on `--rate 1300000 --batch 2000`, `columns=1166 graded=1166` with
|
||||
`draw 3.24/21.43 ms` and `segments 1340/4157` against `graded=0`, `draw 2.30/25.76 ms` and
|
||||
`segments 131/4306` over the same `lod=453` frames. The segment rise *is* the gradient (alpha is
|
||||
quantised into steps, one `LineBatch` and one draw call each, because the line pipeline's colour
|
||||
is a uniform) and the ~1 ms is its cost; a `raw` that *grows* when the grading is on is the
|
||||
level-floor invariant in [invariants-rendering.md](invariants-rendering.md) gone, which measured `raw=1154 lod=0` and 6.36 ms.
|
||||
**`draw`'s max must sit
|
||||
at a frame near 0** — that is the warm-up (glyph atlas, first vertex-buffer growth); a
|
||||
late `F` is a frame whose own geometry was expensive and worth tracing. `tick`'s max is
|
||||
the publish stall: `publish_event()` builds the snapshot's pyramids under `mutex()`, so a
|
||||
latch at over 1 MS/s costs one ~40 ms tick, which must not produce `gaps`. Both these
|
||||
lines and the disarm probe's `axis=`/`snap=` need a fast source to say anything —
|
||||
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000` — since at the default 10 kS/s a
|
||||
whole window fits in the raw path and the publish is too short to lose a race. (Do not
|
||||
raise `--batch` to chase the rate: a batch of 20000 exceeds the datagram limit and the mock
|
||||
dies with `OSError: [Errno 90] Message too long`.)
|
||||
- `publish cost: N events, A/B ms (mean/worst, blocks the UI); M pauses, C/D ms (mean/worst,
|
||||
blocks the producer)` — what *freezing a view* costs, and the line that attributes the stall
|
||||
`frame cost:`'s `tick` max can only hint at. The two publishes are counted apart because they
|
||||
stall **opposite threads**, and nothing else in the dump distinguishes them:
|
||||
`publish_event()` runs on the producer with `mutex()` already held, so its cost lands on the UI
|
||||
as a long tick; `publish_pause_view()` runs on the UI thread and takes the lock, so its cost
|
||||
lands on the producer and can only surface as `gaps`. Read the event figure against `tick`'s max
|
||||
and the pause figure against `gaps`. Both are **wide**: on the fast mock the event publish has
|
||||
read **69, 89, 92 and 115 ms** and the pause publish **22 and 86 ms** across runs, so treat the
|
||||
line as an order of magnitude and not a figure to compare a change against at the millisecond.
|
||||
The event reading matters anyway because it is far above the ~40 ms the `frame cost:` entry above
|
||||
quotes — treat that 40 as the old reading and this line as the authority.
|
||||
It is also **the number to judge a second live ring against**, which is the one design question
|
||||
it exists to answer: freezing a whole `BufferStore` copies nothing and inherits the pyramid a
|
||||
`ChannelBuffer` already maintains incrementally, so both `worst` figures would go to **0** — at
|
||||
the price of a second budget's worth of memory (README records that one live ring is why
|
||||
`--buffer-mb 200` really is 200 MB) and a second per-sample write on the producer. What the line
|
||||
says about that trade is that the stall is real but **costs no packets**: an A/B of the same run
|
||||
with and without a pause read `gaps=0` both ways, the paused one with an 86 ms publish, so a
|
||||
second ring would buy a smoother frame at a latch rather than recovered data. (An earlier run
|
||||
reported `gaps=73` with a pause against 0 without, which is what made the stall worth measuring;
|
||||
it has not reproduced and did not scale with the publish length — 79 gaps with the pyramid build
|
||||
removed against 73 with it — so it was incidental load on one run. Do not quote it as a cost.)
|
||||
- `trace ink: N paints checked, C collapsed, S staircase, H short, worst cover P%, worst
|
||||
drawn-vs-quoted D px over R raw paints` plus a
|
||||
`[ink] paint N <flags> mode=.. axis=[..] n=.. idx=[..) spp=.. level=.. pts=.. kept=..
|
||||
ink=[..] cover=..% x0=.. x_at=[..] bounds=[..] rel=[..] dx=.. drops=[..]
|
||||
back=..` line per anomalous frame (stderr) — **`collapsed` and `staircase` must both be 0**,
|
||||
and **`drawn-vs-quoted` must stay far under a pixel** (0.0000 is what a clean run reads).
|
||||
This is the only check that can see a frame whose *waveform* vanished: `staged_segments`
|
||||
counts geometry including the grid and the instruments, and `lod_traces` counts a trace whose
|
||||
points are then collapsed, so both read healthy through the flicker. The probe runs on every
|
||||
painted frame that drew a trace and recomputes what the renderer did, through the same calls
|
||||
— `neighbours()` for the index window, the 64-spp gate for the level, `gather()`, then the
|
||||
increasing-suffix filter — and reports where the ink actually landed against the axis.
|
||||
`collapsed` is fewer than two distinct positions left (nothing to draw at all), `staircase`
|
||||
is the filter dropping over three quarters of the points, `short` is ink reaching under a
|
||||
quarter of the axis (reported only once the data spans the window, since a filling ring is
|
||||
*meant* to leave its left side empty). `drawn-vs-quoted` is the other reading, and it is the
|
||||
one that names a *defect of position* rather than of presence: at a raw level a gathered pair
|
||||
**is** sample `first + k`, so the line differences where a sample is drawn
|
||||
(`axis.min + gather[2k]`, the renderer's own reconstruction) against the x a readout quotes
|
||||
for it (`x_at()`, exact f64) and reports the worst of them in pixels. That is the
|
||||
gather-origin invariant in [invariants-rendering.md](invariants-rendering.md) read from the
|
||||
screen's side; it was over a hundred pixels at a
|
||||
deep zoom before the origin became the caller's. It is measured on the raw levels only — above
|
||||
them a gathered point is a bucket's envelope and has no single sample to be off from — so `R`
|
||||
is legitimately below `N`. **`back` ≈ one ring span is the signature** of the torn `x0()` read
|
||||
(see the cached-first-stamp invariant); so is an `ink=` range disjoint from `axis=`, or an
|
||||
`idx=` covering the whole ring with `cover` far over 100%. There was a `kept_fixed_x0` field
|
||||
beside `kept` — the same filter with `x0()` hoisted out of the loop, since the renderer used to
|
||||
re-read `x0()` per point and a ring-backed one advances with the producer. The origin is a
|
||||
local of the renderer's now, so that failure mode is structurally impossible and the field is
|
||||
gone; the `[ink]` walk and the renderer still agree because both add `axis.min`. It is a race,
|
||||
so it needs load and a reason for long frames —
|
||||
`tools/mock_udpstreamer.py --rate 1300000 --batch 2000 --clock-jitter 0.4` with the
|
||||
measurements *and* cursors panes up:
|
||||
`UDPSCOPE_ENABLE_ALL_MS=1000 UDPSCOPE_PROBE_MEASURES_MS=2000 UDPSCOPE_PROBE_CURSORS_MS=3500
|
||||
UDPSCOPE_EXIT_AFTER_MS=35000`, which reported three staircase frames in each of three runs
|
||||
while the defect was live and none after. `frame path:`'s own numbers move with it (`segments`
|
||||
max 156078 → 19681), since a refused `neighbours()` gathers the whole ring.
|
||||
- `traceless paints: N (M after the first bind, last at paint P)` and `worst side/pane min
|
||||
width: W px`, plus `[gen] frame F generation A -> B (bound=.. empty=..)` and `[width-probe]
|
||||
...` lines (stderr) — three cheap per-frame counters that came out of the flicker hunt and
|
||||
each guard a documented invariant from the side the existing lines cannot see. **`M` must be
|
||||
only the frames in a state that draws nothing**: a traceless paint is legitimate in
|
||||
`DisplayMode::Waiting`, which is why the line carries `mode=` — `UDPSCOPE_EXIT_AFTER_MS` arms
|
||||
the trigger before it dumps, so a tail of them at the end of a run is expected. `[gen]` names
|
||||
each buffer rebuild as it happens with the bind state around it: a rebuild drops every ring's
|
||||
history, so the frames after one legitimately draw an empty plot, and `blank view frames`
|
||||
cannot see that (it asks whether the ring holds samples the view has not got, and a rebuilt
|
||||
ring holds none either) — so an unexplained `[gen]` burst is a structural change firing when
|
||||
nothing structural happened. `worst side/pane min width` is the "layout must not depend on
|
||||
content" invariant read from the **demand** side: a `GtkLabel`'s minimum width is its text and
|
||||
a minimum wider than the screen is honoured by growing the toplevel, which hands the plot an
|
||||
allocation wider than the window; `axes.w` sees that a frame later and cannot name the cause,
|
||||
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='Choosing a source' 1563 B; shortcuts: app=12 plot=13; about version=… commit=… built=… settings=… icon=org.udpscope.app; 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). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `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, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close 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.
|
||||
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
|
||||
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
|
||||
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
|
||||
`UDPSCOPE_PROBE_MENUS_MS`) — the two popovers the toolbar and the status bar open, measured with
|
||||
the side panel hidden. **`rows_bad` must be 0 and `fits` must be 1.** What it asserts is what a
|
||||
screenshot can only show an eye: every row's label starts at the *same* x (`label_x=26` for all
|
||||
seven — a menu whose checkable rows indent their text and whose others do not is exactly what
|
||||
"the text is not left-aligned" looks like), every shortcut ends at the row's own right edge
|
||||
(`accel_right == W`), no row is over `kMenuRowMaxPx` (30) tall — Adwaita's button minimum is 34,
|
||||
and seven of those is what made the popover read as a stack of dialogs until `.udpscope-menu`
|
||||
trimmed it — and the rows arrive in the order its `kExpected` names. `[menu] status` is the ⓘ
|
||||
bubble: **`pos` must read `top`**, which is set *after* `gtk_menu_button_set_popover()` because a
|
||||
`GtkMenuButton` states a position of its own and the last word wins, and `button_top` must exceed
|
||||
the bubble's height, because "below the button" is off the window's bottom edge — the bubble's
|
||||
top edge stayed at the button with its body clipped away, which reads as an arrow disconnected
|
||||
from its body. Run it **alone**: another probe that opens a window dismisses the popover, and
|
||||
every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses
|
||||
itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores
|
||||
it afterwards.
|
||||
- `[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).
|
||||
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
|
||||
- `[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 (`connecting`, `live`, `paused`, `armed`, `capturing`, `event held`, `disarmed` — a word added to it must be **no wider than the widest already there**, which is what let `connecting` join at ten characters beside `event held`; the dead-port glance reads `max_glance_chars=51` against a live run's 54), the rate field is `%.3g` (so `10 kS/s`, never `10.0 kS/s`), and the two fields a *state* adds are both elapsed times — trigger mode's `last event … ago` and a pause's `for <elapsed>` — which step units rather than growing digits (`format_elapsed()`). A **paused** glance is not in the dump at all: it is read by `[pause-probe]`'s `glance=` field, since the dump's own line is taken live. `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.
|
||||
@@ -0,0 +1,180 @@
|
||||
# Verification
|
||||
|
||||
How to run the headless self-check, the two baselines every run is read against, and the environment variables that schedule each probe.
|
||||
|
||||
Part of [CLAUDE.md](../../CLAUDE.md).
|
||||
|
||||
There is no unit test suite. The only automated check is the app's own headless
|
||||
self-check, driven by env vars and run under a display (`xvfb-run`, llvmpipe) against
|
||||
a live UDPStreamer source:
|
||||
|
||||
```sh
|
||||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||
./build/udpscope --server 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
Two baselines every run is read against. A clean build emits **four** warnings and no more: two
|
||||
`-Wmissing-field-initializers` on `MathNode::source` and `-Woverlength-strings` on **both**
|
||||
generated GResource `.c` files — the help pages' and the icons', whose blob crossed C99's
|
||||
4095-byte string-literal limit when the two app icons joined the four action ones (a note about a
|
||||
generated file, which GCC itself supports). The fifth warning that used to appear here was an unused
|
||||
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
|
||||
`output_after_add` already printed on the same line — and that deletion is what dropped the baseline
|
||||
from five to four. Nothing started reading it. And an exit-dump run emits **no** `Gtk-CRITICAL` at
|
||||
all. A fifth warning, or any critical, is a finding.
|
||||
|
||||
An *incremental* build reads **two** of those four: the generated GResource `.c` files are not
|
||||
recompiled when only `src/` changed, so their `-Woverlength-strings` do not reappear.
|
||||
|
||||
That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_widget_get_width`,
|
||||
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
|
||||
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
|
||||
the lucky reading of it. A run that had built and destroyed other windows first —
|
||||
`UDPSCOPE_PROBE_HELP_MS`, which opens three — has had that memory reused, and the same two calls
|
||||
then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a
|
||||
crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the
|
||||
window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the
|
||||
`panel controls:` line was already written that way for the same reason, and that comment is the
|
||||
precedent, not an exception.
|
||||
|
||||
- `UDPSCOPE_EXIT_AFTER_MS` — run the trigger-on → go-live probe, close the window,
|
||||
then dump the whole pipeline state to stdout (frame/sample counts, per-plot painted
|
||||
frames, trace bindings, panel sizes, status line in both states).
|
||||
- `UDPSCOPE_ENABLE_ALL_MS`, `UDPSCOPE_ARM_TRIGGER_MS`, `UDPSCOPE_PROBE_DISARM_MS`,
|
||||
`UDPSCOPE_TRIG_TRACE_MS` — schedule individual probes. `UDPSCOPE_PROBE_DISARM_MS` only
|
||||
*starts* its sequence: it switches the trigger on and then polls for a capture to open,
|
||||
so the Disarm lands ~300 ms into one rather than wherever a fixed delay happened to fall.
|
||||
- `UDPSCOPE_PROBE_FIELDS_MS` — change the trigger panel's edge and Normal/Single
|
||||
dropdowns. As much a crash test as a state check (see the GTK signal-signature invariant
|
||||
below), so time it inside a capture: `[fields-probe] ... capturing=1` followed by no
|
||||
SIGSEGV *is* the assertion.
|
||||
- `UDPSCOPE_PROBE_WINDOW_MS` — drive the window control every way a user can: type a
|
||||
number, change the unit under it, step a decade either way.
|
||||
- `UDPSCOPE_PROBE_LAYOUT_MS` — drive the layout editor: switch it on, insert above the
|
||||
first row, remove a row, append one at the bottom, switch it off. Its steps are 200 ms
|
||||
apart on purpose — the buttons are placed from the rects the *last frame* was laid out
|
||||
with, so a synchronous sequence would read every position one action stale. It clicks by
|
||||
emitting `::clicked`, never `gtk_widget_activate()`, which holds a `GtkButton` for its
|
||||
250 ms `ACTIVATE_TIMEOUT` and then emits into a button the rebuild has already destroyed.
|
||||
- `[measure-probe] N measurements on 'X'` (stderr, `UDPSCOPE_PROBE_MEASURE_MS`) — adds one
|
||||
measurement of each statistical type (average, top, peak to peak, amplitude) on the first
|
||||
*channel* and forces a recompute. It must name a **channel**, not `signals().front()`: the
|
||||
first signal of a stream is its timestamp, which is not acquired, and naming it adds four
|
||||
measurements that all read `--` *and* makes a performance run look free (each bails before
|
||||
collecting anything -- that is how the first baseline of the stall below read 0.4 ms).
|
||||
The values are in the exit dump as `measurements: <type>=<value>`; on the fast mock
|
||||
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read
|
||||
`peak to peak=2`, `amplitude=1`, `average=0` and **`top=0.636620`** -- 2/pi, the half-wave
|
||||
mean of a sine, which is the check that the threshold types are not merely plausible.
|
||||
`[measure-trace] view=[..] <type>=<value>... marks=N level:V/time:V...` is a repeating line
|
||||
(250 ms) from the probe: the pane's numbers beside the *current* window and the marks actually
|
||||
placed, which is the only way a stale result or a mark that should not be drawn shows up as a
|
||||
number. It must never print `marks=0` while measurements with `show_on_plot` are on and the
|
||||
data is there -- that was the rulers blinking off at a mode change.
|
||||
`measure cost: N recomputes, X ms total, Y ms each, data D ms (P%), covered=C buckets=B
|
||||
raw=R (F%), N ns/covered sample` follows it: **read it against a run with
|
||||
`UDPSCOPE_PROBE_MEASURE_MS` unset**, because a frame mean cannot attribute a cost. The last
|
||||
three fields are the assertion, and they are a *ratio*, not a time: `covered` is the samples
|
||||
the window spans, `buckets` the LOD buckets the two walks answered from, and `raw` the samples
|
||||
they still had to read. **`raw` must stay at a fraction of a percent of `covered`** on a clean
|
||||
waveform over a window of millions -- 0.1% is what the pyramid-backed walks give (see the
|
||||
measurement-cost invariant in
|
||||
[invariants-measurements.md](invariants-measurements.md)). A `raw` near `covered` means the walks went back to reading
|
||||
the window, which at 1 MS/s is 19.7 ms per recompute and a third of the run's frames. It is
|
||||
*legitimately* higher for a window shorter than a bucket (64 samples) or a signal noisy
|
||||
enough to cross a threshold everywhere, so read it on the fast mock
|
||||
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples.
|
||||
`--noise 0.1` there reads **2.0%** at 1.96 ms a recompute against the clean 0.1% at 1.41 ms, and
|
||||
that is the interferer's own cost rather than a regression -- the figure to compare a change
|
||||
against is the **clean** one.
|
||||
Note the **final** `measurements:` line and `[measure-trace]`'s last lines may read `--`:
|
||||
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so the dump can land in `Waiting`
|
||||
with nothing bound and the axis at its default. The race-free reading is an earlier
|
||||
`[measure-trace]` line, which carries its own `view=`; those lines are also where the
|
||||
level-mark fallback is visible (an invalid pass must still print its `level:` marks).
|
||||
|
||||
- `[pan-probe] asked=[..] was=[..]`, `[pan-probe] after=[..] held=D asked=[..]`, then
|
||||
`[pan-probe] zoom asked=[..] after=[..] span=S window=W limited=L`
|
||||
(stderr, `UDPSCOPE_PROBE_PAN_MS`) — **`held` must be 1**: a gesture must not move a window
|
||||
that is pinned (trigger mode, or a paused view). The probe writes the panned range the way a
|
||||
gesture does — on the *member* axis, which `Figure::layout()` adopts into the group — and
|
||||
reports the axis the frame after it produced. Time it *inside* one pinned state: a pan during
|
||||
a capture whose check lands after the capture has ended is re-framed by the mode change, not
|
||||
by the pin, and reads `held=1` with the hold removed (that is how the first version of this
|
||||
probe fooled me). The pause probe makes a stable pin, so run them together:
|
||||
`UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_PAN_MS=2200` puts the pan and its check inside
|
||||
the pause. With `hold_pinned_window()` disabled it reads `held=0` and the axis sits at the
|
||||
asked range. It then asks for a **zoom-in** (a window a quarter of what is on screen) and
|
||||
reports `group=`/`member=` beside it: **`allowed` must be 1, with both equal to the ask** --
|
||||
that is the single-trigger-mode case, where the wheel has to keep working, and where reading
|
||||
the wrong one of the two made a working clamp look broken. All three verdicts are read from the
|
||||
*tick*, one frame after the ask: a `g_timeout` was the first version and it drifted past the
|
||||
paused/resumed boundary under load. The probe then asks for a **zoom-out** ten times the current span and reports
|
||||
the span the next frame produced: **`limited` must be 1**, i.e. `span <= window`. Run it with
|
||||
a ring that holds *more* than the window (`--buffer-mb 64` past the window's own seconds) —
|
||||
that is the state the old `max(retained, window_s_)` bound leaked in, and it showed up here as
|
||||
`span=2.502550 window=2.000000 limited=0` against `span=2.000000` after the fix.
|
||||
|
||||
- `[zoom-probe] <sweep>/x<step> axis=[..] span=.. n=N idx=[a..b) spp=.. level=L pts=P kept=K
|
||||
back=.. ink=[..] x_at_first=.. gap_px=.. end_px=.. rect=[..]` (stderr,
|
||||
`UDPSCOPE_PROBE_EVENTZOOM_MS`) — arms the trigger in **single** mode, polls for a **held
|
||||
event** (a latch happens when the stream says so; a sweep on a timer has nothing to zoom
|
||||
into), then zooms in eight doublings
|
||||
twice: `ges/` writes the row's own axis the way a gesture does, `win/` drives the window
|
||||
control (`apply_time_window`, which unpins and re-arms the live follow before the pin is
|
||||
restated). Each step's reading is taken in the *tick*, after that frame's pin and clamp — a
|
||||
`g_timeout` would report the ask. It recomputes what `Renderer::draw_traces` is handed, through
|
||||
the same calls: `neighbours()` for `idx`, the 64-spp gate for `level`, then `gather()`.
|
||||
**`gap_px` and `end_px` must stay within a pixel or two at every step** — they are how much of
|
||||
the left and right margin no geometry reaches, and a `gap_px` that *doubles* per step is the
|
||||
"zooming into a held event clips it more and more from the left" defect (374 px of a 716 px
|
||||
axis by `ges/x128`), whose cause is a constant offset in data seconds (see the `neighbours()`
|
||||
invariant in [invariants-rendering.md](invariants-rendering.md)). `kept`/`back` are the renderer's own increasing-suffix filter counted rather
|
||||
than applied: `kept` far below `pts` is a staircase, and a non-zero `back` is a non-monotonic
|
||||
gather. **Run it with `--clock-jitter`** (`tools/mock_udpstreamer.py --rate 1300000 --batch
|
||||
2000 --clock-jitter 0.4`): the ideal mock's stamps sit on a perfect grid, which is exactly
|
||||
what hides this class of bug, and three runs against it reported `gap_px=0.0` throughout while
|
||||
the defect was live. The jitter varies each *packet's* spacing rather than offsetting its
|
||||
start, because the stream's clock has to stay strictly increasing. The `win/` sweep bottoms
|
||||
out at `span=0.05` (`RowSet::set_time_window()`'s floor) and walks to the event's post side,
|
||||
since `set_time_window()` frames the newest span before the pin clamps it — both are expected.
|
||||
- `UDPSCOPE_PROBE_PAUSE_MS` — pause acquisition mid-run so layout numbers are
|
||||
measured on a still picture. It now also asserts what a pause *is*
|
||||
(`[pause-probe] <action> paused=D ring_produced=N event_size=M frozen_x=[..] axis=[..]
|
||||
glance=[..]` on stderr):
|
||||
it presses the **button**, not `Acquisition::set_paused()`, because pausing is a display
|
||||
state as well as an acquisition one and the view snapshot and axis pin live in the
|
||||
handler. Across the three lines **`ring_produced` must grow** (the producer never
|
||||
stops — it fills the live ring exactly as it does with the trigger off), **`event_size`
|
||||
must be non-zero exactly while paused** (the plot is bound to the frozen snapshot, not
|
||||
the ring) and **`frozen_x` must not move between the pause and the resume**; after the
|
||||
resume `event_size` is 0 and the window is back on the newest data. Pausing used to stop
|
||||
the producer, which is why the counts grew by nothing and the resume left a hole.
|
||||
`frozen_x` is the *rebased* snapshot — the pause view's own stamps are relative to the freeze
|
||||
instant now, so the range reads `[-window..0]`-ish rather than stream seconds, and a
|
||||
`frozen_x` in the thousands is the rebase gone.
|
||||
**`glance=` is the assertion for the paused status field**, and this is the only line that can
|
||||
make it: the exit dump's `status bar:` is read live and its two `mode …` lines are the live and
|
||||
triggered states. While paused it must read `paused · <rate> · <window> · for <elapsed>`, with
|
||||
the elapsed figure **growing between the two paused lines** and **absent on the resume line**.
|
||||
It is **wall** time (`g_get_monotonic_time()`), not stream time, because the producer never
|
||||
stops while paused and "how long have I been looking at this" is a question about the user, not
|
||||
about the stream; it is written through `format_elapsed()` so the field steps to a coarser unit
|
||||
instead of growing digits (see the layout rule).
|
||||
- `UDPSCOPE_PROBE_COLOR_MS` — recolour one plotted signal and read the colour back out of the
|
||||
trace and the chip, round-trip it through the session writer, then clear it. Schedule
|
||||
`UDPSCOPE_ENABLE_ALL_MS` before it: with nothing bound it has no trace to recolour and asserts
|
||||
nothing. Its four lines are in
|
||||
[probe-readings.md](probe-readings.md).
|
||||
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
|
||||
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
|
||||
only the file chooser.
|
||||
|
||||
**Run one self-check at a time.** Two concurrent `xvfb-run -a` instances end up on the
|
||||
same display and the second gets no GL context — it dumps `layout over 0 frames` and
|
||||
`valid=-1`, which looks like a pipeline failure and is not one. `tools/mock_udpstreamer.py`
|
||||
also outlives its `--duration`, so kill it after each run or the leftovers pile up and
|
||||
hold ports.
|
||||
|
||||
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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,236 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# Source chooser at startup
|
||||
|
||||
Launched with no `--server`, udpscope connects to `127.0.0.1:8000` because that is the
|
||||
default in `Config`. There is no way to pick a source from the application, and no way to
|
||||
see which sources it already knows about — a user who runs it against several streamers has
|
||||
to remember each host and port and retype them. This adds a pre-flight dialog that lists
|
||||
the sources with a stored session and offers a form for a new one.
|
||||
|
||||
## Scope
|
||||
|
||||
In: the chooser, the connection form, the stored connection settings a chooser row needs to
|
||||
reconnect, and the probes that check them.
|
||||
|
||||
Out: switching source without restarting; editing a stored source in place (forget it and
|
||||
re-add); network discovery; a "test connection" button. Each is a separate feature and none
|
||||
is needed to pick a source at startup.
|
||||
|
||||
## When the chooser appears
|
||||
|
||||
`Config` gains `bool server_specified = false`, set only by the `--server` branch of
|
||||
`parse_args()`. `main()` runs the chooser if and only if it is false. `Config::server` keeps
|
||||
its `127.0.0.1` default, so nothing downstream has to cope with an unset host.
|
||||
|
||||
The condition is `--server` alone, not "no arguments": `--multicast` still needs a server to
|
||||
fetch CONFIG from, so a run that gives a group but no server is a run that must still be
|
||||
asked which host to ask.
|
||||
|
||||
Every headless probe documented in CLAUDE.md passes `--server`, so none of them reaches the
|
||||
chooser and none changes behaviour. That is the property that makes this safe to add to a
|
||||
project whose entire regression net is those probes.
|
||||
|
||||
## Lifecycle
|
||||
|
||||
```
|
||||
main()
|
||||
parse_args --server absent?
|
||||
| yes
|
||||
choose_source(cfg) gtk_init(), dialog, nested GMainLoop
|
||||
| false (cancelled) -> return 0
|
||||
| true (chosen)
|
||||
ScopeModel(cfg) \
|
||||
Acquisition(cfg) | unchanged
|
||||
run_ui(cfg, ...) /
|
||||
```
|
||||
|
||||
The chooser runs before `ScopeModel` exists, so a cancel costs no buffer allocation and no
|
||||
socket, and `run_ui()`, `ScopeModel`, `Acquisition` and `RowSet` are untouched. `gtk_init()`
|
||||
followed by a nested `GMainLoop`, and then later `gtk_application_new()` +
|
||||
`g_application_run()` inside `run_ui()`, works: verified in a throwaway program before this
|
||||
design was settled (`gtk_init()` is idempotent, and running your own loop is the documented
|
||||
GTK4 replacement for the removed `gtk_dialog_run()`).
|
||||
|
||||
Cancel exits **0**, not an error: declining to choose a source is not a failure.
|
||||
|
||||
## Known sources
|
||||
|
||||
`layout_store` gains:
|
||||
|
||||
```cpp
|
||||
struct KnownSource {
|
||||
std::string host;
|
||||
std::uint16_t port = 0;
|
||||
std::string multicast; ///< empty = unicast
|
||||
std::string interface_addr;
|
||||
int rows = 0; ///< plots the session had
|
||||
int signals = 0; ///< named slots across those plots
|
||||
std::int64_t mtime = 0; ///< for the ordering; not shown
|
||||
};
|
||||
|
||||
std::vector<KnownSource> known_sources();
|
||||
```
|
||||
|
||||
It scans `config_dir()` for `*.toml`, splits each stem at the **last** `_` into host and
|
||||
port (the host part may itself contain `_`, since `layout_path()` replaces unsafe characters
|
||||
with it), parses the file with the existing `load_layout()`, and keeps the entry only when
|
||||
at least one row slot names a signal. Sorted by `mtime`, newest first.
|
||||
|
||||
This lives in `layout_store` because that file already owns `config_dir()`, the
|
||||
`<host>_<port>.toml` scheme and the parser. A chooser that re-derived any of the three would
|
||||
be a second expression of the same decision, which is how two parts of this codebase have
|
||||
disagreed before.
|
||||
|
||||
**The filter is what makes the list usable.** A development machine accumulates one session
|
||||
file per probe run on a throwaway port — 140 of them at the time of writing, all with
|
||||
`signals = ["", "", "", ""]`. Requiring a named signal means the list shows sources somebody
|
||||
actually configured.
|
||||
|
||||
`forget_source(const KnownSource&)` deletes the file, for the chooser's `×`.
|
||||
|
||||
## Stored connection settings
|
||||
|
||||
A session file is named `<host>_<port>.toml`, so its name cannot say whether that source was
|
||||
multicast or which interface it joined on. `LayoutState` therefore gains:
|
||||
|
||||
```cpp
|
||||
std::string multicast; ///< empty = unicast
|
||||
std::string interface_addr; ///< empty = default route
|
||||
```
|
||||
|
||||
`save_layout()` emits them, when non-empty, in the **top block** (layout_store.cpp:325-338,
|
||||
before the first `[[plot]]`). This is not a stylistic choice: in TOML a bare key written
|
||||
after a table header belongs to that table, which is exactly how the `enabled` key was
|
||||
parsed into the last `[[calibration]]` record and silently lost. `load_layout()` reads them
|
||||
under `Section::Top`. A file written by an older build has neither key and loads as unicast.
|
||||
|
||||
The values come from `Config` and never change at runtime, so `App` carries them beside
|
||||
`source_host`/`source_port` and `collect_layout()` writes them back verbatim.
|
||||
|
||||
The host in the filename stays the CONFIG server even for a multicast session, as today; the
|
||||
group is an attribute of that source rather than its identity.
|
||||
|
||||
## The chooser dialog
|
||||
|
||||
New `src/source_dialog.{hpp,cpp}`:
|
||||
|
||||
```cpp
|
||||
/// Asks which source to open. Returns false if the user declined, in which case `cfg` is
|
||||
/// untouched and the caller should exit. Fills `cfg.server`/`port`/`multicast`/
|
||||
/// `interface_addr` on true.
|
||||
bool choose_source(Config& cfg);
|
||||
```
|
||||
|
||||
A separate file rather than more of `src/ui.cpp`: that file is 7400 lines and already the
|
||||
largest in the project, and this shares no state with it — it runs before the `App` struct
|
||||
exists.
|
||||
|
||||
A `GtkWindow` titled `udpscope — choose a source`, holding a `GtkListBox` inside a
|
||||
`GtkScrolledWindow` (the list is unbounded) and an action row:
|
||||
|
||||
```
|
||||
udpscope — choose a source
|
||||
─────────────────────────────────
|
||||
127.0.0.1:8000 3 plots, 4 signals ×
|
||||
10.0.0.5:8000 1 plot, 2 signals ×
|
||||
10.0.0.5:8001 2 plots, 3 signals, multicast 239.1.2.3 ×
|
||||
─────────────────────────────────────────────────────────────
|
||||
[ New host… ] [ Cancel ]
|
||||
```
|
||||
|
||||
A multicast row is titled by its **server**, not by its group: that is the host the session
|
||||
file is named after and the host CONFIG is fetched from. The group is a line of subtitle.
|
||||
|
||||
- Each row: `host:port` in the normal face over a dimmed `N plots, M signals`, with
|
||||
`multicast GROUP` appended when the session stored one.
|
||||
- Activating a row — click or Enter, i.e. `GtkListBox::row-activated` — fills `cfg` from
|
||||
that `KnownSource` and ends the loop with true.
|
||||
- `×` calls `forget_source()` and removes the row. **No confirmation**: the gesture is the
|
||||
confirmation, and what it destroys is one session's layout, which the app rewrites on the
|
||||
next clean exit. It is a `GtkButton` inside the row, so it must not also activate the row;
|
||||
it is packed at the end and stops there by having its own handler.
|
||||
- `[New host…]` opens the connection form below.
|
||||
- `[Cancel]`, `Escape` and the window's close button all end the loop with false.
|
||||
- With no known sources the list is replaced by a dimmed line (`No saved sources yet.`) and
|
||||
`[New host…]` takes the initial focus, so the dialog is never an empty box.
|
||||
|
||||
## The connection form
|
||||
|
||||
A second `GtkWindow`, transient for and modal on the chooser:
|
||||
|
||||
| Field | Widget | Default | Empty means |
|
||||
| --- | --- | --- | --- |
|
||||
| Server | `GtkEntry` | `127.0.0.1` | — (required) |
|
||||
| Port | `GtkSpinButton`, 1..65535 | `8000` | — |
|
||||
| Multicast group | `GtkEntry` | empty | unicast |
|
||||
| Interface | `GtkEntry` | empty | default route |
|
||||
|
||||
`[Connect]` writes the four fields into `cfg` and ends both loops with true; `[Cancel]`
|
||||
returns to the chooser. `[Connect]` is insensitive while `Server` is empty — the only
|
||||
validation here. A *malformed* address is left to the acquisition layer, which already
|
||||
reports it, and the starvation notice covers a source that accepts the socket and then says
|
||||
nothing; duplicating that check in the dialog would be a second expression of it.
|
||||
|
||||
These four fields and no others. `--buffer-mb`, `--max-window`, `--holdoff`,
|
||||
`--recv-buffer` and `--plots` are tuning rather than "which source", and a form that asks
|
||||
for them is a form a user has to read before using the app.
|
||||
|
||||
Connecting to a host that already has a session file is not a special case: the file is
|
||||
keyed on host and port, so `run_ui()` loads it exactly as it would have.
|
||||
|
||||
## Error handling
|
||||
|
||||
| Case | Behaviour |
|
||||
| --- | --- |
|
||||
| Cancel / close / Escape | exit 0, before any allocation or socket |
|
||||
| `config_dir()` missing or unreadable | empty list, `[New host…]` only |
|
||||
| A session file that fails to parse | `load_layout()` already returns what it parsed; an entry with no named signal is filtered out like any other |
|
||||
| A chosen source that never answers | unchanged from today: CONNECT times out, the client reconnects, the starvation notice fires |
|
||||
| `forget_source()` fails to unlink | the row stays; nothing else to do and nothing was lost |
|
||||
|
||||
## Verification
|
||||
|
||||
Probes, reported on stderr in the style of the existing ones, so this is checked by the same
|
||||
net as everything else rather than a separate harness.
|
||||
|
||||
- `UDPSCOPE_PROBE_SOURCES=1` — print the discovered list and exit without showing the
|
||||
dialog:
|
||||
|
||||
```
|
||||
[sources] 3 known: 127.0.0.1:8000 plots=3 signals=4 | 10.0.0.5:8000 plots=1 signals=2 |
|
||||
239.1.2.3:8000 plots=2 signals=3 multicast=239.1.2.3
|
||||
```
|
||||
|
||||
Assertions: only sources with a named signal appear, order is newest first, and a config
|
||||
directory full of probe leftovers reports them **not at all**.
|
||||
|
||||
- `UDPSCOPE_PROBE_PICK_MS=N` — show the chooser, then after N ms activate row 0 and let the
|
||||
run continue into the real application. A full `xvfb-run` run with **no `--server`** must
|
||||
therefore reach the scope and emit every existing dump line unchanged — that is the
|
||||
end-to-end assertion, and it costs no new dump format.
|
||||
|
||||
- `UDPSCOPE_PROBE_PICK_MS` with `UDPSCOPE_PROBE_NEWHOST=host:port` — drive `[New host…]`
|
||||
instead: open the form, fill it, press `[Connect]`. The crash surface of a dialog built
|
||||
and destroyed is as much the point as the state, the same way `UDPSCOPE_PROBE_FIELDS_MS`
|
||||
is.
|
||||
|
||||
- The multicast round trip, run serially: once with `--server`/`--multicast` to write the
|
||||
file, then `grep multicast` on it, then once with no `--server` at all, checking the row
|
||||
reads `multicast` and that `cfg` came back with the group.
|
||||
|
||||
CLAUDE.md gains these under Verification plus an invariant for the TOML top-block rule and
|
||||
the `--server`-absent condition; README.md gains the feature and why the list is filtered.
|
||||
|
||||
## Files touched
|
||||
|
||||
| File | Change |
|
||||
| --- | --- |
|
||||
| `src/config.hpp/.cpp` | `server_specified` flag |
|
||||
| `src/layout_store.hpp/.cpp` | `KnownSource`, `known_sources()`, `forget_source()`, `multicast`/`interface` keys |
|
||||
| `src/source_dialog.hpp/.cpp` | new: the chooser and the form |
|
||||
| `src/main.cpp` | call the chooser when `--server` was absent |
|
||||
| `src/ui.cpp` | carry multicast/interface on `App`, write them in `collect_layout()` |
|
||||
| `CMakeLists.txt` | the new source |
|
||||
| `CLAUDE.md`, `README.md` | the documentation this project requires for both |
|
||||
@@ -0,0 +1,252 @@
|
||||
# App icon, Adwaita About dialog, and desktop entry
|
||||
|
||||
Date: 2026-10-04
|
||||
|
||||
## Goal
|
||||
|
||||
Three related pieces of desktop integration udpscope currently lacks:
|
||||
|
||||
1. An application icon drawn to the GNOME HIG, in both full colour and symbolic form.
|
||||
2. An About dialog that shows it, built on `AdwAboutDialog` rather than `GtkAboutDialog`.
|
||||
3. A `.desktop` entry, so the app can be launched from the shell rather than only from a terminal.
|
||||
|
||||
## Decisions taken before design
|
||||
|
||||
**The application ID stays `org.udpscope.app`.** GNOME convention is that the application ID, the
|
||||
icon name and the desktop file's basename are one string, so that string is `org.udpscope.app`
|
||||
throughout: the icon is named `org.udpscope.app`, the desktop file is
|
||||
`org.udpscope.app.desktop`, and `gtk_application_new()` is left exactly as it is. The alternative
|
||||
(moving to `it.ferrarinas.Udpscope`, the reverse-DNS of the project's actual forge domain) was
|
||||
offered and declined. Nothing else in the tree depends on the ID — the config directory is
|
||||
`~/.config/udpscope` and the app is `G_APPLICATION_NON_UNIQUE` — so this choice costs nothing
|
||||
beyond the naming itself.
|
||||
|
||||
**The icon is the instrument face**, not a bare waveform and not a colour version of the existing
|
||||
trigger icon. A dark panel carrying one bold trace is the clearest available metaphor, it is the
|
||||
kind of physical object the HIG's top-and-front depth language was written for, and its silhouette
|
||||
survives being scaled down better than either alternative.
|
||||
|
||||
**The desktop entry is installed by an opt-in CMake target**, not by `install()` rules and not by
|
||||
documented `cp` commands. The project deliberately has no install step — that is the stated reason
|
||||
its icons are a GResource — and a target that does nothing until asked preserves that while still
|
||||
producing a working entry. `install()` rules were rejected because they would create a second copy
|
||||
of the app that can diverge from the build tree; manual `cp` was rejected because `Exec` would need
|
||||
the binary on `PATH`, which it is not.
|
||||
|
||||
## The HIG constraints this is built to
|
||||
|
||||
Taken from <https://developer.gnome.org/hig/guidelines/app-icons.html>:
|
||||
|
||||
- Nominal canvas **128×128**, which the artwork should *not* fill.
|
||||
- A **2px grid** for all detail.
|
||||
- Depth comes from combining the top and front of an object: a front profile along the bottom,
|
||||
**no taller than 2 detail units (4 nominal px)**.
|
||||
- Flat colour on straight surfaces; **gradients reserved for curved surfaces**.
|
||||
- Shadows **avoided** where possible.
|
||||
- The standard GNOME palette.
|
||||
- A **symbolic version is required**, reusing the same metaphor, following the UI-icon (single
|
||||
colour) rules.
|
||||
- The icon must still read at **64px** and **32px**.
|
||||
|
||||
## Component 1 — the artwork
|
||||
|
||||
Two new source files, both added to the existing `src/icons/udpscope.gresource.xml` under the
|
||||
`scalable/apps/` tree (the icons already there are `scalable/actions/`).
|
||||
|
||||
### `src/icons/scalable/apps/org.udpscope.app.svg`
|
||||
|
||||
Full colour, 128×128, every coordinate on the 2px grid:
|
||||
|
||||
| element | geometry | palette colour |
|
||||
|---|---|---|
|
||||
| chassis | rounded rect (12, 22)–(116, 106), radius 8 | Dark 3 `#3d3846` |
|
||||
| front profile | the bottom band of that same silhouette, y 102–106 | Dark 2 `#5e5c64` |
|
||||
| screen | rounded rect (20, 30)–(108, 94), radius 4 | Dark 4 `#241f31` |
|
||||
| trace | one sine cycle, centred y = 62, amplitude 18, over x 24–104, 6 px thick | Green 1 `#8ff0a4` |
|
||||
|
||||
The chassis is **one** rounded rect and the front profile is the bottom 4px *of that silhouette*,
|
||||
drawn as a path that follows its bottom corners — not a separate full-width rectangle underneath it.
|
||||
A plain rect from (12, 102) to (116, 106) would protrude past the radius-8 corners, which at 128px
|
||||
reads as a misdrawn bump and at 32px as a dirty edge.
|
||||
|
||||
That band is exactly the HIG's 4px maximum and is the only depth treatment: no gradients (every
|
||||
surface here is straight, so flat colour is what the HIG asks for) and no shadows.
|
||||
|
||||
The artwork occupies x 12–116 and y 22–106, leaving 12px side margins and 22px top and bottom, so it
|
||||
does not fill the canvas. Every coordinate above is even.
|
||||
|
||||
Two deliberate omissions. There is **no graticule** — the grid lines a real scope face carries
|
||||
cannot survive 32px, and the trace already carries the metaphor without them. And the trace is
|
||||
**one** cycle, a single trough and a single crest, rather than several: at 32px the screen is about
|
||||
22×16 px, and anything busier reads as a smudge.
|
||||
|
||||
### `src/icons/scalable/apps/org.udpscope.app-symbolic.svg`
|
||||
|
||||
16×16, the same metaphor reduced to a screen frame with a wave inside it. Unlike the colour icon
|
||||
this is **filled paths only, with an `evenodd` hole for the screen's interior**, because GTK's
|
||||
symbolic recolour only reaches `fill` on `path`, `rect` and `circle` — a stroke keeps the colour
|
||||
written in the file and stays dark on a dark theme. Whole-pixel coordinates throughout.
|
||||
|
||||
**This asymmetry is the trap worth documenting:** CLAUDE.md's existing icon-authoring rules say
|
||||
"everything is a filled `path`" without qualifying it, because every icon in the tree so far has
|
||||
been symbolic. A 128px colour icon is never recoloured, so it may stroke freely — and stroking is
|
||||
the sane way to draw a smooth sine. The rule becomes "fills only in a symbolic icon".
|
||||
|
||||
## Component 2 — the About dialog
|
||||
|
||||
`adw_about_dialog_new()` returns an `AdwDialog*`. An `AdwDialog` is **not** a `GtkWindow`, which is
|
||||
the trap CLAUDE.md already documents for the shortcuts overview, so in `src/help_window.cpp`:
|
||||
|
||||
- `g_about` changes from `GtkWidget*` to `AdwDialog*`.
|
||||
- It is cleared on `::closed`, not `::close-request` (`AdwDialog` has no such signal).
|
||||
- It closes with `adw_dialog_close()`, not `gtk_window_close()`.
|
||||
- It is presented with `adw_dialog_present()`.
|
||||
- Both `AdwDialog`s can share one `::closed` handler keyed by a pointer-to-pointer, leaving
|
||||
`on_window_close` to the help window alone.
|
||||
|
||||
`close_help_windows()` therefore becomes one `GtkWindow` (help) plus two `AdwDialog`s (About,
|
||||
shortcuts), and both its doc comment and the CLAUDE.md invariant that says "two of the three are
|
||||
`GtkWindow`s" have to be revised to "one of the three".
|
||||
|
||||
Field mapping:
|
||||
|
||||
| `GtkAboutDialog` | `AdwAboutDialog` |
|
||||
|---|---|
|
||||
| `program_name` | `application_name` |
|
||||
| `version` | `version` |
|
||||
| `comments` | `comments` |
|
||||
| `license_type` | `license_type` |
|
||||
| `system_information` | `debug_info` |
|
||||
| — | `application_icon` = `"org.udpscope.app"` |
|
||||
| — | `developer_name`, `website` |
|
||||
|
||||
Two gains are taken while mapping. `debug_info` renders as a Troubleshooting page with a copy
|
||||
button, where `system_information` was inert text. And the string behind it is built as a
|
||||
`std::string` rather than the `char details[1024]` a previous review flagged for silent truncation
|
||||
on a long settings path — removing the ceiling rather than widening it, in code already being
|
||||
rewritten.
|
||||
|
||||
Credits, release notes and an issue URL are deliberately **not** added; nothing asked for them.
|
||||
|
||||
One ordering detail: `gtk_icon_theme_add_resource_path()` currently runs at the top of
|
||||
`build_toolbar()`. The main window's own icon is now set from a name in the same resource, so the
|
||||
registration moves up into `run_ui()`, before both `gtk_window_set_icon_name()` and the
|
||||
`build_toolbar()` call — otherwise whichever of the two runs first resolves its name to nothing.
|
||||
|
||||
## Component 3 — the desktop entry
|
||||
|
||||
### `data/org.udpscope.app.desktop.in`
|
||||
|
||||
```
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=udpscope
|
||||
GenericName=Oscilloscope
|
||||
Comment=An oscilloscope for UDPStreamer sources
|
||||
Exec=@UDPSCOPE_EXEC@
|
||||
Icon=org.udpscope.app
|
||||
Terminal=false
|
||||
Categories=Development;Science;Electronics;
|
||||
Keywords=oscilloscope;UDP;signal;waveform;trigger;
|
||||
StartupNotify=true
|
||||
```
|
||||
|
||||
`@UDPSCOPE_EXEC@` is configured to the built binary's absolute path in the build tree. The
|
||||
consequence to accept knowingly: **moving or deleting the build directory breaks the entry.** That
|
||||
is the price of having no install step, and it is the right price here because the entry then always
|
||||
launches exactly the binary that was last built.
|
||||
|
||||
No `StartupWMClass` is needed. GTK takes the WM class from the application ID, and the desktop
|
||||
file's basename already matches it, which is what associates a running window with this entry.
|
||||
|
||||
### The `desktop-entry` target
|
||||
|
||||
A custom target driving `cmake/install_desktop_entry.cmake`, which writes three files under
|
||||
`$XDG_DATA_HOME` (defaulting to `~/.local/share`):
|
||||
|
||||
- `applications/org.udpscope.app.desktop`
|
||||
- `icons/hicolor/scalable/apps/org.udpscope.app.svg`
|
||||
- `icons/hicolor/symbolic/apps/org.udpscope.app-symbolic.svg`
|
||||
|
||||
The two SVGs are copied from `src/icons/scalable/apps/` in the source tree, not extracted from the
|
||||
GResource: the same two files serve both the in-binary resource and the installed icon theme, so
|
||||
there is one copy to edit.
|
||||
|
||||
It then refreshes `update-desktop-database` and `gtk4-update-icon-cache` if either is on `PATH`, runs
|
||||
`desktop-file-validate` if it is available, and prints every path it wrote.
|
||||
|
||||
`cmake --build build` is unaffected: the target runs only when named. No uninstall target is
|
||||
provided — the three paths are printed, so undoing it is three `rm`s.
|
||||
|
||||
## Verification
|
||||
|
||||
Two silent failures are possible, and they fail differently.
|
||||
|
||||
A GResource path that was never registered draws `image-missing` while every other line of the dump
|
||||
passes; the existing `toolbar icons:` line already guards that class with
|
||||
`gtk_icon_theme_has_icon()`. But a **malformed SVG is worse**: librsvg refuses the whole file,
|
||||
`has_icon()` still returns true because the *name* resolves, and the icon draws nothing. `has_icon`
|
||||
cannot see it — and these SVGs have already been bitten once by librsvg, which rejects a file for a
|
||||
`--` inside an XML comment.
|
||||
|
||||
The two are therefore caught in two different places, because **no runtime GTK call can see the
|
||||
malformed one.** This was measured rather than assumed, and three candidate checks were tried and
|
||||
rejected:
|
||||
|
||||
- `gtk_icon_theme_lookup_icon()` never returns `NULL` and its paintable's intrinsic size is the
|
||||
size that was *asked for*, so it reads `128x128` for a valid icon, a malformed one and a name
|
||||
that does not exist at all. The same three cases snapshot to an identical `GSK_TEXTURE_NODE` of
|
||||
the requested bounds, since GTK substitutes `image-missing`. There is nothing to compare.
|
||||
- `GMarkup` accepts `<!-- a -- b -->` without complaint. It is a deliberately lenient subset
|
||||
parser, so it cannot stand in for the strict one that refuses the file.
|
||||
- `gdk_pixbuf_new_from_file()` on an SVG goes through glycin on this system, which sandboxes a
|
||||
subprocess per load, emits fontconfig errors, and refuses this project's existing icons outright
|
||||
for reasons of its own. Not a usable oracle.
|
||||
|
||||
So the malformed file is caught at **build time**, which is better than a probe anyway: every
|
||||
`<file>` line in `src/icons/udpscope.gresource.xml` gains `preprocess="xml-stripblanks"`, which
|
||||
makes `glib-compile-resources` pipe each SVG through `xmllint`. A double hyphen inside a comment
|
||||
then **fails the build** with the file, line and column, instead of drawing nothing at runtime.
|
||||
The four existing icons already pass it. The cost is that the `xmllint` binary becomes a build
|
||||
dependency; it ships with libxml2, which GTK already requires.
|
||||
|
||||
The runtime line then asserts what a runtime check genuinely can:
|
||||
|
||||
```
|
||||
app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)
|
||||
```
|
||||
|
||||
`ok` is `gtk_icon_theme_has_icon()` **and** the resource bytes being present under the registered
|
||||
path, and the size is the one the file's root `<svg>` element declares, read out of those bytes.
|
||||
**Both entries must read `ok` at those sizes.** That covers the unregistered-path case, a renamed
|
||||
or mis-pathed file, and a canvas size that drifted off the HIG's 128 — and it is computed on the
|
||||
first tick beside `toolbar icons:`, not in the dump, which runs after the window is destroyed.
|
||||
|
||||
Alongside it:
|
||||
|
||||
- The `[help]` probe gains `about icon=org.udpscope.app`, read back through
|
||||
`adw_about_dialog_get_application_icon()`, so the dialog is asserted to carry the icon rather than
|
||||
merely to open.
|
||||
- That probe's existing `closed ok` becomes a stronger crash test, because About is now an
|
||||
`AdwDialog` closed by a different call than the help window beside it. It must still produce no
|
||||
`Gtk-CRITICAL` beyond the two already baselined.
|
||||
- `desktop-file-validate` runs inside the `desktop-entry` target, so a malformed entry fails at the
|
||||
moment it is asked for rather than being silently ignored by the shell later.
|
||||
|
||||
## Documentation
|
||||
|
||||
- **CLAUDE.md**: the `app icons:` dump line as a documented assertion, and the `xml-stripblanks`
|
||||
build-time check beside the five existing icon-authoring rules — the sixth rule is that the
|
||||
malformed-SVG failure is a *build* error now and no runtime check can replace it; the windows invariant revised
|
||||
to one `GtkWindow` and two `AdwDialog`s; the `desktop-entry` target and the build-path caveat; and
|
||||
the icon-authoring rules amended so "filled paths only" is scoped to symbolic icons.
|
||||
- **README.md**: the feature narrative — the icon's HIG constraints and the two omissions
|
||||
(graticule, multiple cycles) with the 32px reasoning, the move to `AdwAboutDialog` and what it
|
||||
gained, and how to get the desktop entry.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- An AppStream `metainfo.xml`. Nothing asked for it, and it is only required for store submission.
|
||||
- Redrawing the four existing action icons.
|
||||
- A nightly/devel icon variant.
|
||||
- An uninstall target for the desktop entry.
|
||||
@@ -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,452 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
|
||||
# The math node editor: wires you can draw and delete, and a canvas you can navigate
|
||||
|
||||
The math channel editor works as a *form* and fails as a *graph editor*. Three things were
|
||||
reported — edges cannot be added, edges cannot be deleted, and the canvas cannot be zoomed or
|
||||
panned — and all three are true. The first is a defect with a specific cause, the second has no
|
||||
code path at all, and the third is an absence. Beyond them the editor asks the user to obey a
|
||||
rule the data structure has (a node may only read nodes above it in the list) rather than
|
||||
maintaining that rule itself, and it reports an invalid graph in a modal alert at the end
|
||||
instead of on the canvas as it is being built.
|
||||
|
||||
This redesigns the canvas interaction. Nothing below changes `MathNode`, `MathChannel`, the
|
||||
evaluator in `src/math_channel.cpp`, the session format, or the producer's path.
|
||||
|
||||
## Scope
|
||||
|
||||
In: the canvas view transform, the port and wire hit tests, the gesture state machine
|
||||
(node drag / wire drag / background pan / wheel zoom), edge creation and deletion, a
|
||||
topological re-sort that lets a wire be drawn in either direction, an op picker on the add
|
||||
button, a live validation line that replaces the modal alert, a tidy/auto-layout pass, the
|
||||
`Cancel` button's close path, and the `[math-probe]` assertions for all of it.
|
||||
|
||||
Out: parameters drawn on the node boxes (explicitly declined — the form beside the canvas is
|
||||
where a node's values live). No new ops. No change to `math_channel_valid()`'s rules, to the
|
||||
evaluator, to `MathRuntime`, or to how a derived channel is acquired. No node *library* panel,
|
||||
no copy/paste, no undo.
|
||||
|
||||
## Why edges cannot be added today
|
||||
|
||||
`math_canvas_pressed` (`src/ui.cpp:3455`) handles an output-port hit like this:
|
||||
|
||||
```cpp
|
||||
const int port = math_port_at(d, x, y, which);
|
||||
if (port == -2) {
|
||||
const int node = math_node_at(d, x, y);
|
||||
d->pending_out = (node >= 0 && d->pending_out == node) ? -1 : node;
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
`math_port_at` throws away *which* node's output was hit — it returns the sentinel `-2` — and
|
||||
the handler recovers the node with `math_node_at()`, which matches only **inside the box**. The
|
||||
output square is drawn at `x = n.x + kNodeW`, i.e. straddling the right edge: half of it is
|
||||
outside. Its hit radius is `kPort` (±10) while it is drawn at `±kPort * 0.5` (±5), so the
|
||||
clickable region extends 10 px past the edge and `math_node_at()` answers `-1` for every pixel
|
||||
of it beyond the first. `pending_out` is then set to `-1` — a *silent disarm*. Clicking the
|
||||
visible dot's outer half, which is most of what the eye aims at, arms nothing, and the input
|
||||
squares never light up. That is the whole of "it is not possible to add edges": the mechanism
|
||||
exists and the first click of the two-click sequence usually fails.
|
||||
|
||||
It is worth fixing the cause and not only the symptom, because the same re-derivation would
|
||||
break again under the view transform below.
|
||||
|
||||
Edge *deletion* needs no diagnosis: no path in the file ever writes `-1` back into `in_a` or
|
||||
`in_b` except `on_math_node_deleted`, which is deleting the whole node.
|
||||
|
||||
## The view transform
|
||||
|
||||
`MathDialog` gains three doubles:
|
||||
|
||||
```cpp
|
||||
double view_scale = 1.0; ///< canvas pixels per graph unit
|
||||
double view_x = 0.0; ///< canvas pixels: where graph (0,0) is drawn
|
||||
double view_y = 0.0;
|
||||
```
|
||||
|
||||
Graph coordinates are what `MathNode::x`/`y` already are and what the session file stores, so a
|
||||
view change is never persisted — reopening the editor shows the graph at 1:1, which is the
|
||||
right default for a window whose size the user did not choose either.
|
||||
|
||||
Two conversions, and **every** hit test goes through the second:
|
||||
|
||||
```cpp
|
||||
// canvas <- graph: applied once to the cairo context, never per item.
|
||||
cairo_translate(cr, d->view_x, d->view_y);
|
||||
cairo_scale(cr, d->view_scale, d->view_scale);
|
||||
|
||||
// graph <- canvas: the inverse, for a pointer position.
|
||||
void math_to_graph(const MathDialog* d, double cx, double cy, double& gx, double& gy) {
|
||||
gx = (cx - d->view_x) / d->view_scale;
|
||||
gy = (cy - d->view_y) / d->view_scale;
|
||||
}
|
||||
```
|
||||
|
||||
Applying the transform to the context rather than to each item is what keeps the drawing code
|
||||
unchanged: `math_canvas_draw` keeps every coordinate it has today, and the line widths and font
|
||||
sizes scale with the graph, which is what a zoom should do to a diagram.
|
||||
|
||||
**A hit tolerance is a screen distance, not a graph distance.** A port is aimed at with a
|
||||
pointer, so its clickable radius must be the same number of *canvas* pixels at every zoom —
|
||||
which in graph units is `kPort / view_scale`. The same for the wire tolerance below. Getting
|
||||
this backwards makes the ports unhittable when zoomed out, which is exactly when a user is
|
||||
looking for them.
|
||||
|
||||
**Scale is clamped to `[0.25, 4.0]`.** Below a quarter the op names are unreadable and the
|
||||
boxes are smaller than the pointer tolerance; above four a single node fills the canvas. The
|
||||
clamp lives in one place, a `math_set_scale()` helper, because three callers reach it (wheel,
|
||||
Fit, reset).
|
||||
|
||||
### Wheel zoom about the pointer
|
||||
|
||||
A `GtkEventControllerScroll` with `GTK_EVENT_CONTROLLER_SCROLL_VERTICAL`. The anchor is the
|
||||
graph point under the pointer, and it must not move:
|
||||
|
||||
```cpp
|
||||
double gx = 0.0, gy = 0.0;
|
||||
math_to_graph(d, px, py, gx, gy); // before
|
||||
d->view_scale = clamp(d->view_scale * (dy < 0 ? 1.1 : 1.0 / 1.1), 0.25, 4.0);
|
||||
d->view_x = px - gx * d->view_scale; // restore the anchor
|
||||
d->view_y = py - gy * d->view_scale;
|
||||
```
|
||||
|
||||
The pointer position comes from the controller's own `GtkEventController` current event rather
|
||||
than from a tracked motion position: a scroll event carries one, and tracking motion to supply
|
||||
it is a second source of truth that can be stale.
|
||||
|
||||
No modifier is involved. The canvas has no other use for the wheel, so a bare wheel zoom is
|
||||
unambiguous here — unlike the plot, where the wheel's modifiers are already spoken for.
|
||||
|
||||
### Pan, Fit, and reset
|
||||
|
||||
Dragging the empty background pans: `view_x = pan_x0 + dx`, from a `pan_x0` recorded at
|
||||
`drag-begin`, for the same total-offset reason the node drag already documents at
|
||||
`src/ui.cpp:3507`.
|
||||
|
||||
A **Fit** button frames every node:
|
||||
|
||||
```
|
||||
bbox over all math_node_rect() (empty graph -> identity view)
|
||||
scale = clamp(min(w_canvas / (bbox.w + 2*pad), h_canvas / (bbox.h + 2*pad)), 0.25, 4.0)
|
||||
view_* = centre the scaled bbox in the canvas
|
||||
```
|
||||
|
||||
`Ctrl+0` resets to `scale = 1`, `view_x = view_y = 0`. Both are one call to a shared
|
||||
`math_fit_view()` / `math_reset_view()` so the button and the accelerator cannot drift apart.
|
||||
|
||||
## The port and wire hit tests
|
||||
|
||||
`math_port_at` is replaced by one that **names the node it found**:
|
||||
|
||||
```cpp
|
||||
/// The port under a *graph* position. Returns the node index, with `which` set to
|
||||
/// `kPortOut` (-1) for its output or 0/1 for an input; `-1` when no port is under it.
|
||||
int math_port_at(const MathDialog* d, double gx, double gy, double tol, int& which);
|
||||
```
|
||||
|
||||
Two changes beyond the signature. It scans **backward**, topmost first, so it agrees with
|
||||
`math_node_at()` about which of two overlapping nodes owns a shared pixel — a disagreement
|
||||
between the two searches is how a click lands on one node and a drag on another. And the
|
||||
caller never re-derives the owner, which is the defect above made structurally impossible.
|
||||
|
||||
The hit radius stays *larger* than the drawn square: ±`kPort` canvas pixels around the port's
|
||||
centre, against a square drawn at ±`kPort * 0.5` — a 20 px target for a 10 px mark. That is
|
||||
deliberate and, once the owner is no longer re-derived, harmless: a generous radius is the
|
||||
difference between a diagram that can be wired and one that cannot. The asymmetry is written
|
||||
down in a comment at the hit test, because it is what the old defect fed on.
|
||||
|
||||
A wire is picked by distance to the curve it is drawn as. The curve is the same cubic the
|
||||
renderer uses (`src/ui.cpp:3401`), so the pick and the ink cannot disagree:
|
||||
|
||||
```cpp
|
||||
/// The wire under a graph position, as (consumer node, which input), or {-1, 0}.
|
||||
/// The bezier is sampled at 24 points and the nearest segment measured; a curve 40 units of
|
||||
/// tread wide over a node pitch of 160 is smooth at that sampling, and the tolerance is 6
|
||||
/// canvas pixels, so a miss by a pixel is not a wrong edge deleted.
|
||||
MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol);
|
||||
```
|
||||
|
||||
Sampling rather than solving: the exact distance to a cubic is a fifth-degree root-find, and 24
|
||||
samples of a curve whose control points are 40 units out is well inside a 6 px tolerance. The
|
||||
sample count is a named constant so a future wider tread can raise it.
|
||||
|
||||
**Order of precedence on a press**: port, then wire, then node body, then background. A port
|
||||
sits on a node's edge and a wire ends on a port, so each later test must only see what the
|
||||
earlier ones refused.
|
||||
|
||||
## The gesture state machine
|
||||
|
||||
One `GtkGestureDrag` serves four gestures, decided entirely by what the press landed on. A
|
||||
`GtkGestureClick` is kept for the selection-only cases, because a click is a drag of zero
|
||||
length and GTK delivers both; `drag-begin` records the mode and `pressed` only ever *selects*.
|
||||
|
||||
```cpp
|
||||
enum class MathDrag { None, Node, Wire, Pan };
|
||||
MathDrag drag_mode = MathDrag::None;
|
||||
int wire_from = -1; ///< Wire: the node whose output the live wire leaves
|
||||
int wire_detach = -1; ///< Wire: the consumer whose input was detached to start it, or -1
|
||||
int wire_detach_which = 0;
|
||||
double wire_x = 0.0; ///< Wire: the pointer, in graph units, for the preview
|
||||
double wire_y = 0.0;
|
||||
double pan_x0 = 0.0;
|
||||
double pan_y0 = 0.0;
|
||||
MathWireRef selected_wire{-1, 0};
|
||||
```
|
||||
|
||||
`drag-begin`, in precedence order:
|
||||
|
||||
- **An output port** → `Wire`, `wire_from = node`, `wire_detach = -1`.
|
||||
- **A wired input port** → `Wire`, `wire_from = <the node it was wired from>`,
|
||||
`wire_detach = node`, `wire_detach_which = which`, and the input is cleared *immediately*.
|
||||
Dragging an existing wire off its input and dropping it elsewhere is one gesture, and
|
||||
clearing on begin is what makes the preview show the truth: the wire the user is holding.
|
||||
Dropping it on nothing therefore *detaches*, which is the agreed detach gesture, and needs
|
||||
no separate case.
|
||||
- **An unwired input port** → `None` (select the node; there is nothing to drag from an empty
|
||||
input). Wiring is always output→input, so this is not a connect gesture.
|
||||
- **A node body** → `Node`, exactly as today.
|
||||
- **Anything else** → `Pan`.
|
||||
|
||||
`drag-update` moves the node, the live wire's endpoint, or the view, by mode.
|
||||
|
||||
`drag-end` resolves `Wire`:
|
||||
|
||||
- Released on an **input port** of a node that is not `wire_from` → `math_connect()`.
|
||||
- Released on an **input port of `wire_from` itself** → refused, with "a node cannot read
|
||||
itself" on the validation line. A self-edge is the one cycle a topological sort cannot
|
||||
break, and refusing it by name reads better than "the graph has a cycle".
|
||||
- Released anywhere else → nothing further. If the drag began by detaching, the detachment
|
||||
stands.
|
||||
|
||||
`Escape` during a `Wire` drag cancels and **restores** a detached input: a cancel must undo the
|
||||
clear-on-begin, or Escape would be a way to delete an edge by accident.
|
||||
|
||||
A `GtkEventControllerKey` on the canvas handles `Delete`/`BackSpace` (clear `selected_wire`),
|
||||
`Escape` (cancel a drag, else clear the wire selection) and `Ctrl+0` (reset the view). It is
|
||||
added to the canvas and the canvas is given `gtk_widget_set_focusable(TRUE)` plus focus on
|
||||
press, because a drawing area is not focusable by default and a key controller on an unfocused
|
||||
widget never fires. **The key controller must not swallow text**: the dialog's name and unit
|
||||
entries are `GtkEditable`s, and the controller is on the *canvas*, so a key only reaches it
|
||||
while the canvas has focus — the same reasoning as the app-level accelerators' bubble phase,
|
||||
reached by a narrower route.
|
||||
|
||||
## The edge model
|
||||
|
||||
One function owns connection:
|
||||
|
||||
```cpp
|
||||
/// Wire `from`'s output into `to`'s input `which`. Re-sorts the node list when the wire runs
|
||||
/// backwards. Returns false and sets `why` when the wire would make a cycle.
|
||||
bool math_connect(MathDialog* d, int from, int to, int which, std::string* why);
|
||||
```
|
||||
|
||||
and deletion is a one-liner at the call site (`in_a`/`in_b` back to `-1`), because there is
|
||||
nothing to validate in removing an edge: a disconnected input is a state the validation line
|
||||
already names.
|
||||
|
||||
### Backwards wires re-sort the list
|
||||
|
||||
`MathChannel::nodes` is a list walked in order, and the evaluator's correctness rests on a node
|
||||
reading only nodes before it. Today that rule is the *user's* problem: the input ports of nodes
|
||||
above the armed output are not even drawn as armed, so a wire that would run backwards cannot
|
||||
be drawn at all. In a two-column auto-placed graph that is a real obstruction — the node you
|
||||
want to feed is frequently the one that happens to sit earlier in the list.
|
||||
|
||||
The rule becomes the editor's invariant instead. `math_connect()` on a backwards wire
|
||||
(`from > to`) builds the new edge set, runs a topological sort, and commits only if it is
|
||||
acyclic:
|
||||
|
||||
1. Trial edge set = every node's existing inputs, plus the new one.
|
||||
2. Kahn's algorithm over it. Ties broken by **current list index**, so a sort that changes
|
||||
nothing leaves the list alone and a sort that must move something moves as little as
|
||||
possible. (A stable order matters: the node positions on the canvas do not change, so a
|
||||
gratuitous renumber would silently re-point `output` and `selected` for no reason the user
|
||||
can see.)
|
||||
3. On a cycle: nothing is written, `why` is set, and the validation line shows it. The edge is
|
||||
*not* created — this is the one refusal left, and it is the honest one.
|
||||
4. On success: permute `nodes`, then remap through the permutation **everything that holds an
|
||||
index** — `output`, `selected`, every `in_a`/`in_b`, `drag_node`, `selected_wire.node`. A
|
||||
forgotten one is a silent mis-wire, so they are remapped in a single block with a comment
|
||||
naming the set.
|
||||
|
||||
The session format is untouched: what is written is still a list in evaluation order. A file
|
||||
written by an older build still loads, because the sort is an editor operation and an
|
||||
already-ordered list sorts to itself.
|
||||
|
||||
`math_canvas_draw` stops gating a wire on `from < i` once this holds — but keeps the gate as a
|
||||
**defensive skip**, because a `MathChannel` loaded from a file the editor did not write could
|
||||
hold a forward reference, and drawing it would be drawing an edge the evaluator ignores.
|
||||
|
||||
### The armed-port highlight goes away
|
||||
|
||||
`pending_out` and the "input ports of later nodes light up" highlight are both deleted. They
|
||||
exist to serve the two-click protocol and the list-order rule, and neither survives: with a
|
||||
drag gesture the feedback is the live wire following the pointer, and with the re-sort every
|
||||
input is a legal target. What replaces the highlight is a hover accent on the port under the
|
||||
pointer during a `Wire` drag, which says the one thing that still matters — whether releasing
|
||||
here will connect.
|
||||
|
||||
## The live validation line
|
||||
|
||||
`on_math_apply` validates in two steps today (`math_channel_valid()`, then an acquired-source
|
||||
check the validator cannot make) and reports each in a modal `GtkAlertDialog`. A modal alert at
|
||||
the end of a build is the worst time and place to be told; the graph is right there and can say
|
||||
so continuously.
|
||||
|
||||
One extraction:
|
||||
|
||||
```cpp
|
||||
/// Why the graph as it stands could not be saved, or "" when it could. Both checks the Apply
|
||||
/// path made: `math_channel_valid()`'s graph rules and the dialog's own "a Source must name an
|
||||
/// acquired channel", which the validator cannot make because only the dialog sees that list.
|
||||
std::string math_dialog_problem(const MathDialog* d);
|
||||
```
|
||||
|
||||
`math_dialog_sync()` — already the one place that rewrites the form from the model, and already
|
||||
called from every mutation — sets the line's text and `gtk_widget_set_sensitive(apply, problem
|
||||
.empty())`. An insensitive Apply with the reason beside it is a better refusal than a dialog
|
||||
that must be dismissed.
|
||||
|
||||
`on_math_apply` keeps the check and loses the **dialogs**: it calls `math_dialog_problem()` and
|
||||
returns on a non-empty answer, with no `GtkAlertDialog`. The guard has to stay even though the
|
||||
button cannot be clicked while it would fire, because the probe calls `on_math_apply()` directly
|
||||
and must not be able to save a broken graph through it. That `d->apply` pointer is new on
|
||||
`MathDialog`; the button is built after the canvas today, so `math_dialog_sync()`'s first call
|
||||
(at `src/ui.cpp:3972`, after the whole tree) already runs late enough to set it.
|
||||
|
||||
The line is a `GtkLabel` under the canvas, `error` CSS class when non-empty, and
|
||||
`gtk_label_set_ellipsize(END)` with a tooltip carrying the full text — **the layout-must-not-
|
||||
depend-on-content rule applies here as much as to the status bar**: this label sits in a dialog
|
||||
whose canvas is `hexpand`, and a wrapping error message would otherwise widen the dialog on a
|
||||
mere change of graph.
|
||||
|
||||
## The op picker on add
|
||||
|
||||
`add node` always makes a `MathOp::Gain` (`src/ui.cpp:3516`), so adding a low pass is two
|
||||
actions and a dropdown hunt. It becomes a `GtkMenuButton` labelled `add` whose popover holds a
|
||||
flat button per op, built once from `math_op_count()` / `math_op_at(i)` — the accessor order,
|
||||
not the enum order, for the reason `CLAUDE.md` already records about writing an op's index.
|
||||
Each button carries its op index with `g_object_set_data`, so there is no action-name plumbing
|
||||
and nothing to parse.
|
||||
|
||||
The two behaviours that already work are kept: the new node becomes the output when the graph
|
||||
`was_linear`, and placement is the existing grid. Placement gains one thing — a node is placed
|
||||
at the **first free grid cell** rather than at `nodes.size()`, so the slot left by a deletion is
|
||||
reused instead of the grid growing for ever. The overlap rule stands (`CLAUDE.md`: boxes must
|
||||
not overlap, or `math_node_at()`'s topmost-first search returns the wrong one).
|
||||
|
||||
## Tidy / auto-layout
|
||||
|
||||
A `tidy` button lays the graph out left to right by dependency depth:
|
||||
|
||||
```
|
||||
depth(i) = 0 for a Source or an unwired node
|
||||
= 1 + max(depth of its wired inputs) // well-defined: inputs precede i
|
||||
x = 24 + depth * (kNodeW + 48)
|
||||
y = 20 + row_within_depth * (kNodeH + 22)
|
||||
```
|
||||
|
||||
It needs no recursion and no visited set: the list is in evaluation order — the invariant the
|
||||
re-sort now maintains — so the depth of node `i` depends only on nodes before it and a single
|
||||
forward pass computes every depth. Then `math_fit_view()`, because a tidied graph that is off
|
||||
screen is not tidy.
|
||||
|
||||
The column pitch is `kNodeW + 48`, which is 180 against the 160 the current grid uses: 160
|
||||
leaves 28 units for the wire's 40-unit tread, so the curve doubles back on itself between
|
||||
adjacent columns.
|
||||
|
||||
## The `Cancel` button closes rather than destroys
|
||||
|
||||
`src/ui.cpp:3962` connects `Cancel` to `gtk_window_destroy`:
|
||||
|
||||
```cpp
|
||||
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
|
||||
```
|
||||
|
||||
This is the defect `CLAUDE.md` documents under the math channel invariants — "a Cancel that
|
||||
*destroyed* the window left `app.math_dialog` pointing at a dead widget: every later
|
||||
`open_math_dialog()` took the 'already open' branch and presented it" — and it is still live.
|
||||
The fix for it reached the Apply path (`src/ui.cpp:3731`) and the probe (`src/ui.cpp:6672`) and
|
||||
never reached the button. It is `gtk_window_close`, one word, and the probe must assert it,
|
||||
which it does not today: it only ever closes programmatically.
|
||||
|
||||
## What `[math-probe]` must assert
|
||||
|
||||
Every gesture above is reachable from the probe, because the handlers take the `MathDialog*`
|
||||
and plain coordinates — which is already how the drag test at `src/ui.cpp:6694` works. The
|
||||
probe is extended with one line per claim; each is an assertion stated as a `want` beside the
|
||||
reading, in the style of the existing lines.
|
||||
|
||||
- `[math-probe] hit: output at edge+1 -> node N which=-1, at edge+9 -> node N which=-1
|
||||
(want N N)` — the silent-disarm defect, read at two offsets inside the hit radius and outside
|
||||
the box. Both must name the node. **This is the one line that would have caught the reported
|
||||
bug**, and it is the reason the hit test returns the node rather than a sentinel.
|
||||
- `[math-probe] wire: connect 0->1.a wired=W (want 0)` then a backwards
|
||||
`[math-probe] wire: backwards connect 2->0.a order=[..] wired=W reordered=D (want 1)` — the
|
||||
re-sort, with the permuted op order printed so a wrong remap is visible as a wrong list.
|
||||
- `[math-probe] wire: self refused=D problem=[..] (want 1)` and
|
||||
`[math-probe] wire: cycle refused=D problem=[..] (want 1)` — the two refusals, with the
|
||||
cycle built by wiring a two-node pair both ways. `refused=1` **and** the edge absent.
|
||||
- `[math-probe] wire: pick at (x,y) -> (node,which)=(N,K) delete -> in=-1 (want -1)` — the
|
||||
pick samples the curve at its midpoint, which is the one place a wrong tolerance or a wrong
|
||||
curve shows.
|
||||
- `[math-probe] detach: drag from in 1.a to empty -> in=-1 (want -1)`, and
|
||||
`[math-probe] detach: escape restores in=F (want F)` — the clear-on-begin and its undo, which
|
||||
is the half that silently deletes an edge if Escape is forgotten.
|
||||
- `[math-probe] view: zoom scale=S anchor kept dx=.. dy=.. (want 0.0 0.0)` — the graph point
|
||||
under the pointer before and after a wheel step, which is the only reading that distinguishes
|
||||
"zoomed about the pointer" from "zoomed about the origin". Both must be zero to a rounding.
|
||||
- `[math-probe] view: fit scale=S box=[..] all_inside=D (want 1)`, and
|
||||
`[math-probe] view: reset scale=S offset=(..) (want 1.000 (0,0))`.
|
||||
- `[math-probe] view: pan dx=.. dy=.. offset=(..) (want ..)` plus a hit test **after** a pan and
|
||||
a zoom — `port at the same screen point -> node N` — because a hit test that forgot the
|
||||
inverse transform passes every test taken at the identity view.
|
||||
- `[math-probe] tidy: depths=[..] columns=[..] overlaps=D (want 0)` — the overlap count is the
|
||||
assertion (`CLAUDE.md`'s own rule), and the depths say the layout followed the graph.
|
||||
- `[math-probe] validate: problem=[..] apply_sensitive=D` before and after wiring the missing
|
||||
input: `(want non-empty 0)` then `(want empty 1)`.
|
||||
- `[math-probe] cancel: dialog=cleared` — the `Cancel` button's `::clicked` emitted directly,
|
||||
then `app.math_dialog` read back. `STILL SET` is the regression.
|
||||
|
||||
Two things the probe deliberately cannot assert, and which are therefore stated as design
|
||||
constraints rather than checks: that the canvas takes **focus** on press (a synthetic press
|
||||
does not run GTK's focus machinery), and that the live wire *looks* like it follows the pointer
|
||||
(a draw is not readable from a probe). The second is bounded by the first assertion set — the
|
||||
endpoint it is drawn from is `wire_x`/`wire_y`, which the drag test writes and can print.
|
||||
|
||||
The existing lines are all kept. `drag:` in particular stays as it is: the total-offset rule is
|
||||
unchanged by any of this, and the gesture that exercises it is still a node-body drag.
|
||||
|
||||
## Documentation
|
||||
|
||||
Three places, following the project's existing split:
|
||||
|
||||
- **`CLAUDE.md`** — the `[math-probe]` bullet under Verification gains the new readings and
|
||||
their `want`s; the math channel invariants gain the **hit-test-names-its-own-node** rule (a
|
||||
port hit must not be re-derived with a box test), the **tolerance-is-a-screen-distance** rule,
|
||||
the **list order is the editor's invariant, maintained by a topological re-sort** rule with
|
||||
the index-remap set named, and a note that the `Cancel` path is now asserted.
|
||||
- **`README.md`** — a subsection under the math channels describing the editor as it now
|
||||
behaves, and the diagnosis of the silent disarm, since README is where "what was actually
|
||||
built and why" lives.
|
||||
- **`src/help/11-math.txt`** gains the four canvas gestures (drag a port to wire, drag a wired
|
||||
input away to detach, click a wire and press Delete, wheel to zoom and drag the background to
|
||||
pan) and the two buttons. It is an edit to an existing page, so `pages` stays 14 and
|
||||
`[help] pages=14 parsed=14` must still hold — the markup is Pango and a page that fails to
|
||||
parse renders a stand-in.
|
||||
|
||||
## Risks
|
||||
|
||||
- **The re-sort touching an index nothing remapped.** The mitigation is that the remap is one
|
||||
block, the probe prints the permuted op order, and `selected`/`output` are both read back.
|
||||
- **The key controller stealing a keystroke from the name entry.** It is on the canvas, which
|
||||
must have focus; the probe cannot see this, so it is checked by hand once, by typing in the
|
||||
name field with a wire selected.
|
||||
- **The wheel controller fighting a future scrolled window.** There is none and this design
|
||||
removes the reason for one, which is worth stating so a later "wrap the canvas in a
|
||||
`GtkScrolledWindow`" is recognised as undoing the zoom rather than adding to it.
|
||||
- **`GtkGestureDrag` and `GtkGestureClick` on one widget both claiming a press.** They already
|
||||
coexist on this canvas; the change is that `pressed` now only selects and never mutates the
|
||||
graph, which removes the ordering question rather than answering it.
|
||||
+10
-3
@@ -183,9 +183,16 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
||||
used_stream_time = true;
|
||||
break;
|
||||
case UDPS_TIME_FIRST_SAMPLE: {
|
||||
// Only element 0 is meaningful here: the producer stamps the batch's first
|
||||
// sample and says nothing about the rest, so the cadence has to space them --
|
||||
// exactly as LAST_SAMPLE below dates its batch backwards from its anchor.
|
||||
// Deriving a step from `value_of(n - 1)` instead cannot work: a scalar signal
|
||||
// in Accumulate mode still occupies `num_samples` slots on the wire, and a
|
||||
// producer fills them by repeating the anchor, so the step came out as zero
|
||||
// and every sample of the frame landed on one x. The degeneracy guard below
|
||||
// then threw the whole stream onto the arrival clock.
|
||||
const double anchor = value_of(0);
|
||||
const double step = n > 1 ? (value_of(n - 1) - anchor) / double(n - 1) : period;
|
||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * step;
|
||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * cadence;
|
||||
used_stream_time = true;
|
||||
break;
|
||||
}
|
||||
@@ -511,7 +518,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
||||
time_acc_ += rel;
|
||||
++time_acc_n_;
|
||||
if (tick % g == g - 1) {
|
||||
store.time().push(static_cast<f32>(time_acc_ / static_cast<double>(time_acc_n_)));
|
||||
store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
|
||||
time_acc_ = 0.0;
|
||||
time_acc_n_ = 0;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
+13
-13
@@ -24,7 +24,7 @@ const plot::data::MinMaxPyramid& null_pyramid() {
|
||||
// --- ChannelBuffer ----------------------------------------------------------
|
||||
|
||||
ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
||||
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride,
|
||||
std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride,
|
||||
f64 epoch, f64 nominal_dt)
|
||||
: values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride),
|
||||
epoch_(epoch), dt_(nominal_dt) {}
|
||||
@@ -64,11 +64,11 @@ void ChannelBuffer::acquire() noexcept {
|
||||
|
||||
void ChannelBuffer::invalidate() noexcept {
|
||||
published_ = 0;
|
||||
window_x0_ = 0.0f;
|
||||
window_x0_ = 0.0;
|
||||
pyramid_.clear();
|
||||
}
|
||||
|
||||
f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||
f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||
// Channel sample `j` is produced on the tick that also pushes time slot
|
||||
// `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the
|
||||
// time ring every `time_stride` ticks and the channel every `stride` of those,
|
||||
@@ -102,10 +102,10 @@ f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||
// whole trace's geometry collapsing.
|
||||
const u64 base = times_->base();
|
||||
const u64 produced = times_->produced();
|
||||
if (produced == 0) return 0.0f;
|
||||
if (produced == 0) return 0.0;
|
||||
u64 slot = index < base ? base : index;
|
||||
if (slot >= produced) slot = produced - 1;
|
||||
f32 out = 0.0f;
|
||||
f64 out = 0.0;
|
||||
times_->copy_from(slot, 1, &out);
|
||||
return out;
|
||||
}
|
||||
@@ -121,7 +121,7 @@ f64 ChannelBuffer::dx() const {
|
||||
|
||||
f64 ChannelBuffer::x0() const {
|
||||
if (pyramid_.size() == 0) return epoch_;
|
||||
return epoch_ + f64(window_x0_);
|
||||
return epoch_ + window_x0_;
|
||||
}
|
||||
|
||||
f64 ChannelBuffer::x_at(u64 i) const {
|
||||
@@ -131,7 +131,7 @@ f64 ChannelBuffer::x_at(u64 i) const {
|
||||
// ring-span too new made it refuse the whole visible range -- which left the renderer
|
||||
// gathering the entire ring at a coarse level and drawing it off the axis.
|
||||
if (i == 0) return x0();
|
||||
return epoch_ + f64(time_of(absolute(i)));
|
||||
return epoch_ + time_of(absolute(i));
|
||||
}
|
||||
|
||||
bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const {
|
||||
@@ -171,7 +171,7 @@ u64 ChannelBuffer::snap_index(f64 x) const {
|
||||
plot::Range ChannelBuffer::x_bounds() const {
|
||||
const u64 n = pyramid_.size();
|
||||
if (n == 0) return plot::Range{epoch_, epoch_};
|
||||
return plot::Range{x0(), epoch_ + f64(time_of(absolute(n - 1)))};
|
||||
return plot::Range{x0(), epoch_ + time_of(absolute(n - 1))};
|
||||
}
|
||||
|
||||
plot::Range ChannelBuffer::y_bounds() const {
|
||||
@@ -189,7 +189,7 @@ void ChannelBuffer::raw(u64 begin, u64 end, f32* out) const {
|
||||
for (u64 i = count; i < end - begin; ++i) out[i] = 0.0f;
|
||||
}
|
||||
|
||||
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void ChannelBuffer::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode) const {
|
||||
const u64 n = pyramid_.size();
|
||||
const u64 b = std::min(begin, n);
|
||||
@@ -197,7 +197,7 @@ void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_
|
||||
if (b >= e) return;
|
||||
|
||||
const u64 origin = pyramid_.window_base();
|
||||
const f64 base = x0(); // x is emitted relative to this (see the header)
|
||||
const f64 base = x_origin; // x is emitted relative to this (see the header)
|
||||
|
||||
const auto emit_raw = [&](u64 from, u64 to) {
|
||||
for (u64 i = from; i < to; ++i) {
|
||||
@@ -302,13 +302,13 @@ u64 SlotSeries::snap_index(f64 x) const {
|
||||
return buffer_ != nullptr ? buffer_->snap_index(x) : 0;
|
||||
}
|
||||
|
||||
void SlotSeries::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void SlotSeries::gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode) const {
|
||||
if (event_ != nullptr) {
|
||||
event_->gather(level, begin, end, out_xy, mode);
|
||||
event_->gather(level, begin, end, x_origin, out_xy, mode);
|
||||
return;
|
||||
}
|
||||
if (buffer_ != nullptr) buffer_->gather(level, begin, end, out_xy, mode);
|
||||
if (buffer_ != nullptr) buffer_->gather(level, begin, end, x_origin, out_xy, mode);
|
||||
}
|
||||
|
||||
void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
||||
|
||||
+17
-12
@@ -34,8 +34,10 @@ using plot::u64;
|
||||
/// undecimated case and the common one.
|
||||
class ChannelBuffer final : public plot::data::Series {
|
||||
public:
|
||||
/// `values` is f32 and `times` is f64: see `BufferStore::time()` for why the clock
|
||||
/// alone needs the wider type.
|
||||
ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
||||
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride, f64 epoch,
|
||||
std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride, f64 epoch,
|
||||
f64 nominal_dt);
|
||||
|
||||
ChannelBuffer(const ChannelBuffer&) = delete;
|
||||
@@ -71,16 +73,18 @@ class ChannelBuffer final : public plot::data::Series {
|
||||
/// datagram leaves a gap, so index times must come from the time ring.
|
||||
f64 x_at(u64 i) const override;
|
||||
/// Bracketing samples by binary search over the time ring, which is monotonic,
|
||||
/// so a crosshair value is interpolated between the two samples actually either
|
||||
/// side of the pointer -- a grid walk would land on the wrong pair after a gap.
|
||||
/// so a crosshair reads whichever of the two samples actually either side of the
|
||||
/// pointer is nearer -- a grid walk would land on the wrong pair after a gap.
|
||||
bool neighbours(f64 x, u64& i0, u64& i1) const override;
|
||||
u64 snap_index(f64 x) const override;
|
||||
plot::Range x_bounds() const override;
|
||||
plot::Range y_bounds() const override;
|
||||
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||
/// x is emitted **relative to `x0()`** (the library's contract): staging an
|
||||
/// absolute epoch time as f32 would quantise it far coarser than a pixel.
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
/// x is emitted **relative to `x_origin`** (the library's contract): staging an
|
||||
/// absolute epoch time as f32 would quantise it far coarser than a pixel, and
|
||||
/// staging it relative to the whole *retained window* quantises it coarser than a
|
||||
/// pixel of a zoomed view -- see `plot::data::Series::gather()`.
|
||||
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
||||
|
||||
/// `copy_raw`, published so the retargetable trace series can forward the
|
||||
@@ -94,10 +98,10 @@ class ChannelBuffer final : public plot::data::Series {
|
||||
/// Absolute (channel) index of retained-window index `i`.
|
||||
u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; }
|
||||
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
|
||||
f32 time_of(u64 absolute_index) const noexcept;
|
||||
f64 time_of(u64 absolute_index) const noexcept;
|
||||
|
||||
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
|
||||
std::shared_ptr<const plot::data::RingBuffer<f32>> times_;
|
||||
std::shared_ptr<const plot::data::RingBuffer<f64>> times_;
|
||||
u64 stride_ = 1;
|
||||
f64 epoch_ = 0.0;
|
||||
f64 dt_ = 1.0;
|
||||
@@ -107,14 +111,15 @@ class ChannelBuffer final : public plot::data::Series {
|
||||
/// slot cannot be overwritten under the read. Everything else on this object is read at
|
||||
/// *paint* time with no lock, and this one slot is the ring's oldest: the one the producer is
|
||||
/// about to overwrite. Reading it there returned a stamp a whole ring-span too new whenever
|
||||
/// the producer lapped it between the bounds check and the copy -- and `x0()` is the origin
|
||||
/// the renderer reconstructs every trace point from (`gather[i] + series.x0()`), so one such
|
||||
/// the producer lapped it between the bounds check and the copy -- and `x0()` is what
|
||||
/// `neighbours()` tests the visible range against, as well as the origin the renderer
|
||||
/// reconstructed every trace point from before the caller named one, so one such
|
||||
/// read moved the entire polyline a ring-span off its axis (an invisible trace) or, caught
|
||||
/// inside the renderer's increasing-suffix filter, made it drop most of the points. That is
|
||||
/// the "live plot flickers as if there were no data" defect. The cached value is also the
|
||||
/// *right* answer rather than merely a stable one: it is the stamp sample `window_base`
|
||||
/// actually carries, where the clamp answered with whatever slot happened to be oldest now.
|
||||
f32 window_x0_ = 0.0f;
|
||||
f64 window_x0_ = 0.0;
|
||||
plot::data::MinMaxPyramid pyramid_;
|
||||
};
|
||||
|
||||
@@ -161,7 +166,7 @@ class SlotSeries final : public plot::data::Series {
|
||||
plot::Range x_bounds() const override;
|
||||
plot::Range y_bounds() const override;
|
||||
const plot::data::MinMaxPyramid& pyramid() const override;
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
||||
bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; }
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ Config parse_args(int argc, char** argv) {
|
||||
std::exit(0);
|
||||
} else if (a == "--server" || a.rfind("--server=", 0) == 0) {
|
||||
c.server = value_of(argc, argv, i, "--server");
|
||||
c.server_specified = true;
|
||||
} else if (a == "--port" || a.rfind("--port=", 0) == 0) {
|
||||
const std::string v = value_of(argc, argv, i, "--port");
|
||||
const long p = std::strtol(v.c_str(), nullptr, 10);
|
||||
|
||||
@@ -16,6 +16,11 @@ struct Config {
|
||||
std::string multicast; ///< Empty selects unicast.
|
||||
std::string interface_addr; ///< Local IPv4 to join the group on; empty = default route.
|
||||
|
||||
/// Whether `--server` was given. Absent, the application asks which source to open rather
|
||||
/// than silently taking the default above -- so this is "did the user choose", not "is
|
||||
/// `server` valid". The default stays a usable value for every other path.
|
||||
bool server_specified = false;
|
||||
|
||||
/// Size of *each* of the two sample buffers, in megabytes. The application
|
||||
/// always allocates two identical buffers (spec item 1).
|
||||
double buffer_mb = 200.0;
|
||||
|
||||
+29
-13
@@ -26,8 +26,24 @@ using plot::u64;
|
||||
/// live ring buffers switched by a pending flag, plus a display-epoch shift)
|
||||
/// could not stop.
|
||||
struct EventSnapshot {
|
||||
std::vector<f32> stamps; ///< edge-relative seconds, monotonic
|
||||
/// Seconds, monotonic, and **in the coordinates the axis reads**: a trigger event's
|
||||
/// stamps are edge-relative and a pause view's are relative to the instant it froze,
|
||||
/// which is what `x_offset` below records. **f64, like the live ring it is cut from**
|
||||
/// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/
|
||||
/// `publish_pause_view()` a straight copy.
|
||||
std::vector<f64> stamps;
|
||||
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
||||
/// The stream time of this snapshot's x = 0, so `x_offset + stamps[i]` is the clock the
|
||||
/// data arrived on: the edge for a trigger event, the newest sample at the moment of the
|
||||
/// freeze for a pause view.
|
||||
///
|
||||
/// It exists because a snapshot is the one thing on screen whose coordinates outlive the
|
||||
/// `display_epoch()` that produced them, and `ScopeModel::stream_at_x0()` is what reads
|
||||
/// it: an export has to name the stream's own clock whatever is on screen, and the pause
|
||||
/// handler has to put a *display* window back on the ring to cut it. Before it, the
|
||||
/// export added `trigger_time()` to a pause view's stamps -- an unrelated edge, or none
|
||||
/// at all -- for want of anywhere to record which clock a snapshot was on.
|
||||
double x_offset = 0.0;
|
||||
double pre_s = 0.0; ///< seconds of pre-trigger data kept
|
||||
double post_s = 0.0; ///< seconds of post-trigger data kept
|
||||
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
|
||||
@@ -102,7 +118,7 @@ class EventSeries final : public plot::data::Series {
|
||||
/// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever
|
||||
/// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one
|
||||
/// (unlike the ring, whose window slides).
|
||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
||||
void gather(u32 level, u64 begin, u64 end, f64 x_origin, std::vector<f32>& out_xy,
|
||||
plot::data::GatherMode mode =
|
||||
plot::data::GatherMode::Envelope) const override {
|
||||
if (channel_ >= snap_->values.size()) return;
|
||||
@@ -112,17 +128,17 @@ class EventSeries final : public plot::data::Series {
|
||||
const u64 b = std::min(begin, n);
|
||||
const u64 e = std::min(std::max(end, b), n);
|
||||
if (b >= e) return;
|
||||
// x **relative to x0()**, which is the library's gather contract: the renderer
|
||||
// adds `x0()` back before mapping to the screen. Emitting the absolute stamp made
|
||||
// it add x0 twice, so the whole capture was drawn one `x0()` -- a full
|
||||
// pre-trigger window -- to the left of the axis it had just been measured
|
||||
// against. The event's own stamps are small (edge-relative, so [-pre, +post]),
|
||||
// which is why that shift looked like a plausible picture: with a 50/50 split the
|
||||
// right half of the plot was simply empty, zooming out showed the trace ending at
|
||||
// the x = 0 marker, and zooming in past the shift showed nothing at all while the
|
||||
// crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the
|
||||
// right values.
|
||||
const f64 base = x0();
|
||||
// x **relative to `x_origin`**, which is the library's gather contract: the
|
||||
// renderer adds that same origin back before mapping to the screen. Emitting the
|
||||
// absolute stamp made it add an origin twice, so the whole capture was drawn one
|
||||
// `x0()` -- a full pre-trigger window -- to the left of the axis it had just been
|
||||
// measured against. The event's own stamps are small (edge-relative, so
|
||||
// [-pre, +post]), which is why that shift looked like a plausible picture: with a
|
||||
// 50/50 split the right half of the plot was simply empty, zooming out showed the
|
||||
// trace ending at the x = 0 marker, and zooming in past the shift showed nothing
|
||||
// at all while the crosshair -- which reads `x_at()`, never `gather()` -- went on
|
||||
// reporting the right values.
|
||||
const f64 base = x_origin;
|
||||
const auto rel = [&](u64 i) {
|
||||
return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base);
|
||||
};
|
||||
|
||||
+28
-24
@@ -38,7 +38,7 @@ std::string sanitise(const std::string& name) {
|
||||
/// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
|
||||
/// easily sanitise to the same string.
|
||||
std::vector<std::string> column_labels(const ExportTable& table) {
|
||||
std::vector<std::string> taken{"time", "stream_time"};
|
||||
std::vector<std::string> taken{"time", "abs_seconds"};
|
||||
std::vector<std::string> out;
|
||||
out.reserve(table.columns.size());
|
||||
for (const ExportTable::Column& col : table.columns) {
|
||||
@@ -106,24 +106,28 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
// What is on screen: a pause's frozen view, or the trigger's held event. Both are immutable
|
||||
// snapshots, so the export is the picture the user is looking at either way.
|
||||
std::shared_ptr<const EventSnapshot> snap = model.display_snapshot();
|
||||
// The absolute clock of display x = 0, so `abs_base + time[i]` is the number the stream
|
||||
// stamped sample i with -- **one expression, correct in all four display modes**, which is the
|
||||
// whole reason `stream_at_x0()` exists. `epoch()` is the stream time of the first sample ever
|
||||
// seen (the rings store stamps relative to it) and `stream_at_x0()` is the offset of the
|
||||
// display's origin within that, whether it comes from a snapshot or from the live epoch. The
|
||||
// snapshot branch used to add `trigger_time()` here, which named an unrelated edge on a
|
||||
// *paused* export, or none at all before the first latch.
|
||||
const f64 abs_base = model.epoch() + model.stream_at_x0();
|
||||
if (snap != nullptr) {
|
||||
// The snapshot is immutable and already index-aligned, so the visible window is two
|
||||
// binary searches over its stamps and the same index range for every channel.
|
||||
const std::vector<f32>& st = snap->stamps;
|
||||
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
|
||||
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
|
||||
const std::vector<f64>& st = snap->stamps;
|
||||
const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
|
||||
const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
|
||||
if (hi <= lo) return table;
|
||||
const std::size_t first = static_cast<std::size_t>(lo - st.begin());
|
||||
const std::size_t n = static_cast<std::size_t>(hi - lo);
|
||||
// The event's x = 0 is the edge, so the stream clock has to be added back for the
|
||||
// second column -- the one case where the visible x is not the stream's own.
|
||||
const f64 edge = model.trigger_time().load(std::memory_order_relaxed);
|
||||
|
||||
table.time.resize(n);
|
||||
table.stream_time.resize(n);
|
||||
table.abs_seconds.resize(n);
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
table.time[i] = static_cast<f64>(st[first + i]);
|
||||
table.stream_time[i] = edge + table.time[i];
|
||||
table.time[i] = st[first + i];
|
||||
table.abs_seconds[i] = abs_base + table.time[i];
|
||||
}
|
||||
for (const int c : chans) {
|
||||
const std::size_t ch = static_cast<std::size_t>(c);
|
||||
@@ -141,23 +145,23 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||
// the same window hold the same samples. The lock is against a structural change
|
||||
// dropping the rings under the copy, which is what the UI thread has to fear here.
|
||||
std::lock_guard<std::mutex> lock(model.mutex());
|
||||
// The axis reads `display_epoch + stamp`, so the window has to be put back on the
|
||||
// stream's clock before the ring is searched. It is 0 in live mode, but reading it is
|
||||
// what keeps this correct rather than merely true today.
|
||||
const f64 epoch = model.display_epoch();
|
||||
// The axis reads `stamp - stream_at_x0()`, so the window has to be put back on the stream's
|
||||
// clock before the ring is searched -- and that offset is a whole window while streaming now,
|
||||
// not the 0 it once was: the live axis is `[-window, 0]` about the newest sample.
|
||||
const f64 s0 = model.stream_at_x0();
|
||||
u64 first = 0, last = 0;
|
||||
model.live_window_locked(view.min - epoch, view.max - epoch, first, last);
|
||||
model.live_window_locked(view.min + s0, view.max + s0, first, last);
|
||||
const u64 n = last - first;
|
||||
if (n == 0) return table;
|
||||
|
||||
BufferStore& store = model.live();
|
||||
std::vector<f32> stamps(static_cast<std::size_t>(n));
|
||||
std::vector<f64> stamps(static_cast<std::size_t>(n));
|
||||
store.time().copy_from(first, n, stamps.data());
|
||||
table.time.resize(static_cast<std::size_t>(n));
|
||||
table.stream_time.resize(static_cast<std::size_t>(n));
|
||||
table.abs_seconds.resize(static_cast<std::size_t>(n));
|
||||
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
||||
table.stream_time[i] = static_cast<f64>(stamps[i]);
|
||||
table.time[i] = epoch + table.stream_time[i];
|
||||
table.time[i] = stamps[i] - s0;
|
||||
table.abs_seconds[i] = abs_base + table.time[i];
|
||||
}
|
||||
for (const int c : chans) {
|
||||
const std::size_t ch = static_cast<std::size_t>(c);
|
||||
@@ -179,7 +183,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
||||
// `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy
|
||||
// only inserts alignment padding when asked to (`align=True`) -- so the records below
|
||||
// are field widths back to back with no gaps.
|
||||
std::string descr = "[('time', '<f8'), ('stream_time', '<f8')";
|
||||
std::string descr = "[('time', '<f8'), ('abs_seconds', '<f8')";
|
||||
for (const std::string& label : labels) descr += ", ('" + label + "', '<f4')";
|
||||
descr += "]";
|
||||
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" +
|
||||
@@ -211,7 +215,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
||||
std::size_t at = 0;
|
||||
std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
|
||||
at += sizeof(f64);
|
||||
std::memcpy(record.data() + at, &table.stream_time[i], sizeof(f64));
|
||||
std::memcpy(record.data() + at, &table.abs_seconds[i], sizeof(f64));
|
||||
at += sizeof(f64);
|
||||
for (const ExportTable::Column& col : table.columns) {
|
||||
const f32 v = i < col.values.size() ? col.values[i] : 0.0f;
|
||||
@@ -239,7 +243,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
||||
if (!opened(out, path, error)) return false;
|
||||
|
||||
const std::vector<std::string> labels = column_labels(table);
|
||||
std::string line = "time,stream_time";
|
||||
std::string line = "time,abs_seconds";
|
||||
for (const std::string& label : labels) line += "," + label;
|
||||
line += "\n";
|
||||
std::fwrite(line.data(), 1, line.size(), out.f);
|
||||
@@ -252,7 +256,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
||||
line.clear();
|
||||
std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]);
|
||||
line += buf;
|
||||
std::snprintf(buf, sizeof(buf), "%.17g", table.stream_time[i]);
|
||||
std::snprintf(buf, sizeof(buf), "%.17g", table.abs_seconds[i]);
|
||||
line += ",";
|
||||
line += buf;
|
||||
for (const ExportTable::Column& col : table.columns) {
|
||||
|
||||
+16
-7
@@ -24,13 +24,22 @@ struct ExportTable {
|
||||
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
||||
};
|
||||
|
||||
/// The x the plot shows and the crosshair reads: stream seconds in live mode,
|
||||
/// edge-relative seconds in `[-pre, +post]` while an event is held.
|
||||
/// The x the plot shows and the crosshair reads, which is **relative** in every mode: seconds
|
||||
/// before now while streaming (so the newest sample is 0 and the window is `[-window, 0]`),
|
||||
/// seconds from the edge in `[-pre, +post]` while an event is held, and seconds from the
|
||||
/// instant of the freeze on a paused view.
|
||||
std::vector<f64> time;
|
||||
/// The stream's own clock, so two exports taken in different modes line up. Equal to
|
||||
/// `time` in live mode; `time + trigger_time` while an event is held, which is the one
|
||||
/// case where the visible x is not the stream's.
|
||||
std::vector<f64> stream_time;
|
||||
/// The stream's own absolute clock -- the number the time signal carried, which is what the
|
||||
/// source stamped the samples with -- so two exports taken in different modes, or from
|
||||
/// different runs, line up on one timeline.
|
||||
///
|
||||
/// `f64 seconds` rather than the wire's integer, because the integer does not exist in this
|
||||
/// process: `udps_frame_t::values` is a `const double*`, so the client has already widened
|
||||
/// every value by the time acquisition sees it. At Unix-epoch magnitudes an `f64` resolves
|
||||
/// about 240 ns, which is coarser than a 1 MS/s sample period -- so this column is the
|
||||
/// *provenance* of a row and `time` is the column to difference. Its own `np.diff` is
|
||||
/// legitimately not single-valued for that reason.
|
||||
std::vector<f64> abs_seconds;
|
||||
std::vector<Column> columns;
|
||||
|
||||
std::size_t rows() const noexcept { return time.size(); }
|
||||
@@ -45,7 +54,7 @@ struct ExportTable {
|
||||
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
||||
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
||||
|
||||
/// Writes a NumPy `.npy` file holding one structured array: `time` and `stream_time` as
|
||||
/// Writes a NumPy `.npy` file holding one structured array: `time` and `abs_seconds` as
|
||||
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
|
||||
/// about half the size of an all-`f8` one and invents no precision.
|
||||
///
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
<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, and 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 <tt>--buffer-mb</tt>;
|
||||
the drawing thread reads whatever that ring holds when a frame starts, which is why a trace
|
||||
never makes the window stutter.
|
||||
|
||||
The stream is the clock. Every interval you read on screen is measured between timestamps the
|
||||
source sent, never your machine's, so a stalled source freezes the view instead of scrolling it
|
||||
-- and a source whose clock steps still gets the window you asked for.
|
||||
|
||||
The time axis is <i>relative to now</i>. While streaming it is a fixed <tt>[-window .. 0]</tt>
|
||||
with the newest sample at 0, so x reads as an age: <tt>-1.5s</tt> is a sample a second and a half
|
||||
old. In trigger mode 0 is the event's edge instead, and a paused view keeps the ages it was
|
||||
frozen with. The source's own clock is still there when you need it -- an export writes it as a
|
||||
second column.
|
||||
|
||||
<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, which lists the sessions you have opened
|
||||
before. A unicast source serves one client at a time: if a second viewer connects, they take
|
||||
turns and the status bar says so. To watch one source from several instances, use
|
||||
<tt>--multicast</tt> with the group and <tt>--interface</tt> with the local address.
|
||||
|
||||
The signals arrive <i>disabled</i>. Nothing is acquired until you turn one on in the signal
|
||||
list, and enabling is what starts acquisition -- so a freshly opened scope with an idle side
|
||||
panel is working correctly, not failing to connect.
|
||||
|
||||
<b>The window</b>
|
||||
|
||||
• <b>The header bar</b> -- left to right: the time window (<tt>2 s</tt>), the trigger and the
|
||||
pause, then the pane switches -- signal list, cursors, measurements, math, side panel -- and
|
||||
the menu, which holds Fit to data, the layout editor, Export, Keyboard shortcuts, Help and
|
||||
About.
|
||||
|
||||
• <b>The side panel</b> -- the trigger, cursors, measurements and math panes at the top, then
|
||||
the signal list: one row per signal with its colour, an enable switch, its name and its unit.
|
||||
Signals are dragged from here onto a plot row.
|
||||
|
||||
• <b>The plot</b> -- up to 8 rows, each with its own y axis and up to 4 traces, all on one
|
||||
shared time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).
|
||||
|
||||
• <b>The status bar</b>, directly above the plot's bottom edge -- the state word, the stored
|
||||
sample rate and the window, and the ⓘ beside it for packets, gaps and the buffer's own
|
||||
figures. The second screenshot above is what a held capture looks like: <tt>event held</tt>,
|
||||
the axis pinned to the event, and the trigger marker at the edge.
|
||||
|
||||
<b>The first five minutes</b>
|
||||
|
||||
1. <b>Connect.</b> With no <tt>--server</tt>, pick a stored session or type a host and port.
|
||||
2. <b>Enable a signal</b> in the signal list. The plot stays empty for the first fraction of a
|
||||
second while the first window fills -- the buffer holds only what has arrived.
|
||||
3. <b>Drag it onto a row.</b> Drop a second signal on another row, or the same signal again for
|
||||
a second view of it. The colour is per signal, so it is the same one wherever it appears.
|
||||
4. <b>Set the window</b> with the control in the header bar: type a number, pick a unit, or step
|
||||
a decade. The trigger's event window is a share of this span, so a window change moves it.
|
||||
5. <b>Pause</b> (<tt>space</tt>) to look at a still picture; the producer keeps filling the
|
||||
buffer behind it, and resuming shows what arrived meanwhile rather than a hole.
|
||||
6. <b>Arm the trigger</b> (<tt>a</tt>) and wait for a latch: the view freezes on one event, and
|
||||
you can zoom in on it. Disarming mid-capture keeps what the capture got.
|
||||
7. <b>Measure it</b> -- the measurements pane computes a statistic of the visible window, and
|
||||
<b>show on plot</b> marks it as a ruler or a pair of markers.
|
||||
8. <b>Save the data</b> with Export visible range, which writes the plotted signals over the
|
||||
window at the stored rate to <tt>.npy</tt> or <tt>.csv</tt>.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• A signal nobody enables costs nothing -- no buffer, no socket work, no measurements.
|
||||
• The window remembers itself. Rows, traces, the time window, calibration, which signals were
|
||||
enabled, the math channels with their graphs, and the measurements are written per host and
|
||||
port, and reloaded the next time you open that source.
|
||||
• A very wide window on a fast stream is drawn from averaged groups of samples, so the rate in
|
||||
the status bar falls: that is the anti-alias filter, not a fault -- striding would fold a fast
|
||||
signal back as a slow one, which looks entirely plausible on screen.
|
||||
• Every instrument the app computed -- the trigger marker, the measurement marks -- is locked to
|
||||
the pointer's reach. The cursors are yours to drag; those are not.
|
||||
@@ -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,33 @@
|
||||
<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>Click a coloured chip to change that signal's colour.</b> A grey chip names no trace, so
|
||||
it is not clickable. The colour is saved with the session; <b>Default colour</b> in the
|
||||
signal's ⋯ menu gives the palette entry back.
|
||||
|
||||
<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,35 @@
|
||||
<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 pair of side margins, so their y labels line up, their data areas start and
|
||||
end on the same two columns, and their windows cannot drift apart. The left margin holds the y
|
||||
labels; the right one is empty, and is there so the newest tick's label and the newest sample's
|
||||
ink have somewhere to land instead of sitting on the frame.
|
||||
|
||||
<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,33 @@
|
||||
<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.
|
||||
• <b>This control is the only thing that changes the window.</b> A zoom, a pan or a fit moves the
|
||||
view <i>inside</i> it and leaves it alone — which is the point of the previous bullet: a wheel
|
||||
notch that redefined the window could re-plan the buffer and throw your samples away.
|
||||
• 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,57 @@
|
||||
<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.
|
||||
• Nothing you do on the plot changes the <i>time window</i> — only the header control does. A
|
||||
zoom or a pan moves the view inside the window; <b>f</b> (and Fit X axis) is the <i>undo</i> of a
|
||||
zoom, so a 2 s window zoomed into reads 2 s again. The window is more than the axis: it is also
|
||||
the trigger's event span and how the samples are stored, so a wheel notch that redefined it
|
||||
would re-plan the buffer and drop your history.
|
||||
• The axis does not scroll — the <i>data</i> does. A live axis is a fixed <tt>[-window .. 0]</tt>
|
||||
with the newest sample at 0, so the labels stand still and the trace rolls under them. The
|
||||
cursors stand still with the labels (they are positions on screen, so a cursor you placed stays
|
||||
where you put it, over newer data each frame), while a measurement's marks travel with the
|
||||
sample they measured and leave the view when it does.
|
||||
• 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.
|
||||
• A trace holding far more samples than the plot has pixels is drawn <i>one pixel column at a
|
||||
time</i>: each column is a single vertical line from the lowest to the highest value that falls
|
||||
in it. Drawn as a polyline instead, such a window puts three or four near-vertical lines in
|
||||
every column at slightly different sub-pixel positions, and how they overlap beats against the
|
||||
pixel grid — which reads as bands of light and dark that <i>crawl</i> as you pan. One line per
|
||||
column removes the beat, and it is also much faster: a 1 MS/s view costs a couple of thousand
|
||||
line segments a frame instead of a couple of hundred thousand.
|
||||
• Those columns are <i>graded by brightness</i>, the way a digital-phosphor scope reads: bright
|
||||
where the signal spent its time, faint where it only passed through. A column's highest and
|
||||
lowest value are the same whether the samples in it are spread evenly or piled at its two ends,
|
||||
so the grading is what tells the two apart — the flat parts of a square wave come out solid and
|
||||
the edges between them faint. It is read from a finer division of each column rather than from
|
||||
the line that is drawn, and it costs about a millisecond a frame.
|
||||
|
||||
<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,31 @@
|
||||
<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.
|
||||
• The status bar says how long you have been paused — wall time, since the stream itself never
|
||||
stopped. It is there so a frozen picture cannot be mistaken for a live one that went quiet.
|
||||
|
||||
<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,50 @@
|
||||
<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 value is the <i>nearest stored sample's</i>, never a number between two samples. Zoom in past
|
||||
the sample spacing and the line between two samples is something the plot drew, not data —
|
||||
so a reading off it would be the drawing. The hover crosshair reads the same way.
|
||||
• Every reading carries a <i>metric prefix</i> so the digits sit in <tt>1..999</tt>:
|
||||
<tt>10.52 mV</tt>, not <tt>0.01 V</tt>. Two decimals is all an instrument landing on an
|
||||
<tt>f32</tt> sample can claim, so without the prefix a small value would read <tt>0.00</tt>
|
||||
and the reading would be gone. A stream that declares no unit gets the prefix anyway, written
|
||||
tight against the number (<tt>585.24m</tt>) — the space separates the number from its unit,
|
||||
and a spaced <tt>m</tt> with nothing after it would read as metres.
|
||||
• 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. It is <i>on that sample</i>, so when the
|
||||
marker falls between two it sits a little off its own line: the line is where you put the
|
||||
marker, the dot is the datum being quoted. The hover crosshair's mark snaps the same way, and
|
||||
is drawn larger than a trace's sample dots so it does not read as one more sample.
|
||||
• The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
|
||||
time row, by design.
|
||||
• A cursor <i>stays where you put it</i> while a live view rolls under it, which is what a bench
|
||||
scope does: its <tt>T0</tt> is an age, so a marker left at <tt>-1.5s</tt> keeps reading the
|
||||
sample a second and a half old as the stream advances. A measurement's marks do the opposite —
|
||||
they travel with the sample they measured — because a cursor is a question you asked and a mark
|
||||
is an answer about one particular sample.
|
||||
|
||||
<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,54 @@
|
||||
<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 crossing is counted with <i>hysteresis</i>, not on a bare level: the thresholds are a tenth of
|
||||
the window's peak to peak either side of the share, and an edge is counted only once the signal
|
||||
has passed the far one. Period, frequency and pulse duration all depend on it. The mid level is
|
||||
exactly where a waveform moves fastest, so any interferer faster than the fundamental recrosses a
|
||||
single level several times per real crossing — a 5 Hz sine with a 500 Hz tone at a tenth of the
|
||||
amplitude reads 39 Hz without the band and 5 Hz with it. The band holds to an interferer a third
|
||||
of the waveform's own size.
|
||||
• The band is clamped to stay inside the data, which is what keeps a threshold set near an extreme
|
||||
usable: a fixed band there would sit above every sample and the measurement would read nothing.
|
||||
A share near an extreme still leaves the band little room, so it rejects less — which is one
|
||||
reason period and frequency offer no share to set.
|
||||
• 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. A value is written with a <i>metric prefix</i>
|
||||
so its digits sit in <tt>1..999</tt> — <tt>636.62 mV</tt>, not <tt>0.64 V</tt> — including when
|
||||
the stream declares no unit at all, which is always the case for a math channel: there the
|
||||
prefix goes tight against the number (<tt>636.62m</tt>), since two decimals of an unprefixed
|
||||
small value is <tt>0.00</tt>.
|
||||
• 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,61 @@
|
||||
<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, and opening
|
||||
one shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
|
||||
the canvas. Inside the editor, <i>add node</i> is a menu button whose popover picks the op, so the
|
||||
node arrives as the operation you asked for.
|
||||
|
||||
<b>Graph gestures:</b> <b>drag</b> from an output port to an input port to wire them; drag a
|
||||
wired input port off its connection to detach it; click a wire and press <tt>Delete</tt> to remove
|
||||
it; drag a box to move it. <b>Wheel</b> zooms about the pointer. <b>Drag the background</b> to
|
||||
pan. <tt>f</tt> fits all nodes into view; <tt>Ctrl+0</tt> resets the view. <i>tidy</i>, the button
|
||||
beside <i>fit</i> in the editor, lays the graph out in topological columns and frames it. The orange
|
||||
dot marks the output node; click <i>set as output</i> to move it.
|
||||
|
||||
<b>Worth knowing</b>
|
||||
|
||||
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
|
||||
wire drawn right to left re-sorts the list silently so the graph stays valid. A cycle is
|
||||
unrepresentable: the connection is refused rather than created, and the line beneath the form
|
||||
says why — a loop, or a node asked to read itself.
|
||||
• 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.
|
||||
|
||||
<b>Why an operation sometimes does nothing</b>. A graph's <i>output</i> is the node whose value
|
||||
becomes the channel, and it is drawn as the orange dot. A new graph is built source, then operation,
|
||||
and the last node added becomes the output -- but a graph whose output is left on the source is
|
||||
valid and simply passes its input through, which is the usual reason a gain or a filter appears to
|
||||
have no effect. <i>Set as output</i> moves the dot.
|
||||
|
||||
<b>Apply is insensitive while the graph is incomplete.</b> A line beneath the form says why:
|
||||
an input that is not connected, no output chosen, or a <i>channel</i> node naming a signal the
|
||||
scope is not acquiring. Only the channels being acquired are offered, because a source that is not
|
||||
acquired reads as no data for ever — and a graph that reads NaN is harder to diagnose than one that
|
||||
would not save.
|
||||
|
||||
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
|
||||
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
|
||||
against the <i>stored</i> rate, which is the wire rate until a wide window makes the buffer decimate
|
||||
-- the status line's rate is that number, and the filters are rebuilt whenever it changes.
|
||||
@@ -0,0 +1,33 @@
|
||||
<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 — negative ages ending at 0 while
|
||||
streaming, offsets from the trigger edge while an event is held — so it is the x you read a
|
||||
value at on screen. <tt>abs_seconds</tt> is the source's own clock for the same sample, which
|
||||
is what lets two exports taken in different modes line up. 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>abs_seconds</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,46 @@
|
||||
<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 four colours: green <i>alternating</i> lit and dim means data is
|
||||
arriving, and a steady colour means it is not. A badge that never blinks is not a working badge.
|
||||
Of the two steady colours, <i>grey</i> is no stream at all or a pipeline you stopped (the
|
||||
tooltip says which), and <i>yellow</i> is a source that answered but has sent no configuration.
|
||||
• The state field is one word from a fixed set: <tt>connecting</tt>, <tt>live</tt>,
|
||||
<tt>paused</tt>, <tt>armed</tt>, <tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. Two
|
||||
states add one field each: in trigger mode, how long ago the last event was; while paused, how
|
||||
long you have been paused.
|
||||
• <tt>connecting</tt> means there is no <i>signal list</i> yet — either no session or a session
|
||||
with no configuration behind it. Every other word claims a signal list, so until one arrives
|
||||
there is nothing for them to describe: nothing can be enabled and no data is owed.
|
||||
• The paused figure is <i>wall</i> time, not stream time — the producer never stops while paused,
|
||||
so it answers "how long have I been looking at this picture".
|
||||
• 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 data is arriving</i> — is that same situation seen
|
||||
from the other side: the signal list is here, so something took the stream away. A source that
|
||||
has sent <i>no</i> configuration gets a different notice, <i>trying to connect</i>, because
|
||||
nothing was taken away — pointing the app at a port where nothing answers is that case, and the
|
||||
usual cause is a streamer that is not running or is serving another port.
|
||||
• <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,34 @@
|
||||
<?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>
|
||||
|
||||
<!-- The screenshots live with the user documentation (docs/); the resource aliases
|
||||
them under `img/` so a help page can name one by resource path. The path is relative
|
||||
to this manifest's directory. -->
|
||||
<file alias="img/main-window.png">../../docs/img/main-window.png</file>
|
||||
<file alias="img/trigger-capture.png">../../docs/img/trigger-capture.png</file>
|
||||
<file alias="img/math-editor.png">../../docs/img/math-editor.png</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
@@ -0,0 +1,351 @@
|
||||
// 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 each window's own close handler, so a second open cannot present a dead widget.
|
||||
/// One pointer per window, file-scope because there is one application.
|
||||
///
|
||||
/// The help window is a `GtkWindow`. The other two are `AdwDialog`s, which are **not**
|
||||
/// `GtkWindow`s: they have no `::close-request`, so they clear on `::closed` and close with
|
||||
/// `adw_dialog_close()`.
|
||||
GtkWidget* g_help = nullptr;
|
||||
AdwDialog* g_about = nullptr;
|
||||
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
|
||||
}
|
||||
|
||||
/// Shared by both `AdwDialog`s: the pointer to clear is the `user_data`, so there is one
|
||||
/// handler rather than one per dialog.
|
||||
void on_dialog_closed(AdwDialog* dialog, gpointer slot) {
|
||||
(void)dialog;
|
||||
*static_cast<AdwDialog**>(slot) = 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
|
||||
/// The screenshots shown above the text, as resource paths and a null terminator. A page
|
||||
/// with none is the common case and the reason this is a list rather than one field: the
|
||||
/// overview carries two, the window and a held capture.
|
||||
const char* const* images = nullptr;
|
||||
};
|
||||
|
||||
/// 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.
|
||||
/// How tall a page's screenshots are drawn. They are scaled to fit this *height* and centre
|
||||
/// themselves in it, so a 1500x950 capture is readable without a horizontal scrollbar.
|
||||
constexpr int kHelpShotHeight = 260;
|
||||
|
||||
/// The screenshots each page carries, in the order it shows them.
|
||||
constexpr const char* kOverviewShots[] = {"/org/udpscope/help/img/main-window.png",
|
||||
"/org/udpscope/help/img/trigger-capture.png",
|
||||
nullptr};
|
||||
constexpr const char* kTriggerShots[] = {"/org/udpscope/help/img/trigger-capture.png",
|
||||
nullptr};
|
||||
constexpr const char* kMathShots[] = {"/org/udpscope/help/img/math-editor.png", nullptr};
|
||||
|
||||
constexpr HelpPage kPages[] = {
|
||||
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt", kOverviewShots},
|
||||
{"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", kTriggerShots},
|
||||
{"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", kMathShots},
|
||||
{"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());
|
||||
// The screenshots above it, if this page has any. The column is the label's previous sibling
|
||||
// inside the page's box, which is what lets this handler stay a matter of the label alone --
|
||||
// and refilling it is a page *switch*, not a frame, so rebuilding it here is cheap and is
|
||||
// also the only way to be sure the pictures are this page's and not the last one's.
|
||||
GtkWidget* label = GTK_WIDGET(body_label);
|
||||
GtkWidget* parent = gtk_widget_get_parent(label);
|
||||
GtkWidget* shots = parent != nullptr ? gtk_widget_get_first_child(parent) : nullptr;
|
||||
if (shots != nullptr && GTK_IS_BOX(shots)) {
|
||||
while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
|
||||
gtk_box_remove(GTK_BOX(shots), old);
|
||||
}
|
||||
for (const char* const* img = kPages[idx].images; img != nullptr && *img != nullptr; ++img) {
|
||||
GtkWidget* shot = gtk_picture_new_for_resource(*img);
|
||||
gtk_picture_set_can_shrink(GTK_PICTURE(shot), TRUE);
|
||||
// A height, and `CONTAIN` (the default) to fit the image inside it: a picture that may
|
||||
// shrink asks for no height at all in a box, so without this it is allocated nothing and
|
||||
// the page shows its text with a gap where the screenshots should be.
|
||||
gtk_widget_set_size_request(shot, -1, kHelpShotHeight);
|
||||
gtk_box_append(GTK_BOX(shots), shot);
|
||||
}
|
||||
gtk_widget_set_visible(shots, gtk_widget_get_first_child(shots) != nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void show_about(GtkWindow* parent, const std::string& session_path) {
|
||||
if (g_about != nullptr) {
|
||||
adw_dialog_present(g_about, GTK_WIDGET(parent));
|
||||
return;
|
||||
}
|
||||
|
||||
// `AdwAboutDialog` rather than `GtkAboutDialog`: it shows the application icon as the page's
|
||||
// own header, which is the whole reason the app has one, and `adw_about_window_new()` is
|
||||
// deprecated in favour of it. It is an `AdwDialog`, so it is modal on what it is presented
|
||||
// on and needs no transient-for or modal call of its own.
|
||||
AdwDialog* dialog = adw_about_dialog_new();
|
||||
g_about = dialog;
|
||||
AdwAboutDialog* about = ADW_ABOUT_DIALOG(dialog);
|
||||
adw_about_dialog_set_application_name(about, "udpscope");
|
||||
adw_about_dialog_set_application_icon(about, "org.udpscope.app");
|
||||
adw_about_dialog_set_developer_name(about, "Martino Ferrari");
|
||||
adw_about_dialog_set_version(about, build_info::kVersion);
|
||||
adw_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources.");
|
||||
adw_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11);
|
||||
adw_about_dialog_set_website(about, "https://git.ferrarinas.it/martino/udpscope");
|
||||
|
||||
// `debug_info` rather than `system_information`: it renders as a Troubleshooting page with a
|
||||
// copy button, where the old field was inert text a user had to retype. A `std::string`
|
||||
// rather than a fixed buffer, because a long settings path used to truncate it silently.
|
||||
const std::string debug = "Commit: " + std::string(build_info::kCommit) +
|
||||
"\nBuilt: " + build_info::kTimestamp +
|
||||
"\nSettings: " + config_dir() +
|
||||
"\nSession file: " + session_path;
|
||||
adw_about_dialog_set_debug_info(about, debug.c_str());
|
||||
|
||||
g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_about);
|
||||
adw_dialog_present(dialog, GTK_WIDGET(parent));
|
||||
}
|
||||
|
||||
const char* about_icon_name() {
|
||||
if (g_about == nullptr) return "";
|
||||
return adw_about_dialog_get_application_icon(ADW_ABOUT_DIALOG(g_about));
|
||||
}
|
||||
|
||||
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);
|
||||
// The page's own column: its screenshots above its text. Built once and *refilled* by the page
|
||||
// switch, which is why no picture is made here -- one `GtkPicture` per image of the page.
|
||||
GtkWidget* shot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||
gtk_widget_set_margin_start(shot_box, 18);
|
||||
gtk_widget_set_margin_end(shot_box, 18);
|
||||
gtk_widget_set_margin_top(shot_box, 14);
|
||||
gtk_widget_set_visible(shot_box, false);
|
||||
GtkWidget* page_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_box_append(GTK_BOX(page_box), shot_box);
|
||||
gtk_box_append(GTK_BOX(page_box), body);
|
||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), page_box);
|
||||
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_dialog_closed), &g_shortcuts);
|
||||
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) adw_dialog_close(g_about);
|
||||
if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts);
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
#pragma once
|
||||
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <string>
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// The About dialog: the app icon, version, commit, build timestamp and where the session
|
||||
/// files live. An `AdwAboutDialog`, so it is modal on `parent` by being presented on it, and
|
||||
/// it is an `AdwDialog` rather than a `GtkWindow`. `session_path` is this run's own session
|
||||
/// file; it goes on the Troubleshooting page, which has a copy button.
|
||||
void show_about(GtkWindow* parent, const std::string& session_path);
|
||||
|
||||
/// The icon name the open About dialog carries, or "" when it is not open. The probe reads it
|
||||
/// so the dialog is asserted to *show* the app icon rather than merely to open.
|
||||
const char* about_icon_name();
|
||||
|
||||
/// 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. One of them is a GtkWindow -- the help
|
||||
/// window, closed with gtk_window_close(), which emits close-request so its handler runs. The
|
||||
/// other two are AdwDialogs, which are not GtkWindows: they close with adw_dialog_close(),
|
||||
/// which emits ::closed. Either way the file-scope pointer is nulled by the handler, which is
|
||||
/// what prevents a second open presenting a dead widget. Used by the probe.
|
||||
void close_help_windows();
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The symbolic form of the app icon: the same metaphor reduced to a screen frame with one
|
||||
wave inside it, following the UI-icon rules rather than the app-icon ones.
|
||||
|
||||
Filled paths only, with no stroke anywhere. GTK's symbolic recolour wraps the file in a
|
||||
stylesheet that reaches `fill` on `path`, `rect` and `circle` only, so a stroked line keeps
|
||||
the colour written here and stays dark on a dark theme. The wave is therefore a closed
|
||||
2px band: a sine along its top edge and the same sine 2px lower along its bottom, which is
|
||||
how a stroke's look is had from a fill.
|
||||
|
||||
`fill-rule="evenodd"` over three subpaths. The frame is the outer rect minus the inner one,
|
||||
so the screen's interior is two crossings deep and empty; the wave inside it is three, which
|
||||
is odd, so it fills. Whole-pixel coordinates throughout, because this is drawn at 16px far
|
||||
more often than not and a half-pixel feature is a grey smear at 1x.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="#2e3436" fill-rule="evenodd"
|
||||
d="M 1 2 H 15 V 14 H 1 Z
|
||||
M 2 3 H 14 V 13 H 2 Z
|
||||
M 3 7 C 5 3 6 3 8 7 C 10 11 11 11 13 7 L 13 9 C 11 13 10 13 8 9 C 6 5 5 5 3 9 Z"/>
|
||||
</svg>
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The application icon: an oscilloscope's face, which is the clearest metaphor available and
|
||||
the kind of physical object the HIG's top-and-front depth language was written for.
|
||||
|
||||
Drawn to <https://developer.gnome.org/hig/guidelines/app-icons.html>. The artwork spans
|
||||
x 12..116 and y 22..106, so it does not fill the 128px canvas, and every feature sits on
|
||||
the 2px grid. Depth is the 4px front profile along the bottom and nothing else: that is
|
||||
the HIG's maximum of 2 detail units, and since every surface here is straight, the rest
|
||||
is flat colour with no gradients and no shadows.
|
||||
|
||||
The profile is the bottom 4px of the chassis silhouette, clipped to it. A plain rect from
|
||||
(12, 102) to (116, 106) would protrude past the radius-8 corners, which reads as a
|
||||
misdrawn bump at 128px and as a dirty edge at 32px; the clip follows the corners exactly
|
||||
and keeps every coordinate even, which hand-computing the arc would not.
|
||||
|
||||
Two deliberate omissions. There is no graticule: the grid lines a real scope face carries
|
||||
cannot survive 32px, and the trace carries the metaphor without them. And the trace is
|
||||
one cycle, a single crest and a single trough, because at 32px the screen is about
|
||||
22x16 px and anything busier reads as a smudge.
|
||||
|
||||
Colours are the GNOME palette: Dark 3 chassis, Dark 2 profile, Dark 4 screen, Green 1 trace.
|
||||
-->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<defs>
|
||||
<clipPath id="chassis-silhouette">
|
||||
<rect x="12" y="22" width="104" height="84" rx="8"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<rect x="12" y="22" width="104" height="84" rx="8" fill="#3d3846"/>
|
||||
<g clip-path="url(#chassis-silhouette)">
|
||||
<rect x="12" y="102" width="104" height="4" fill="#5e5c64"/>
|
||||
</g>
|
||||
<rect x="20" y="30" width="88" height="64" rx="4" fill="#241f31"/>
|
||||
<path d="M 24 62 C 37 38 51 38 64 62 C 77 86 91 86 104 62"
|
||||
fill="none" stroke="#8ff0a4" stroke-width="6" stroke-linecap="round"/>
|
||||
</svg>
|
||||
@@ -2,16 +2,26 @@
|
||||
<!-- SPDX-License-Identifier: MIT -->
|
||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||
<!--
|
||||
The four icons this app had to draw for itself, compiled into the binary so there is
|
||||
still exactly one file to run. `gtk_icon_theme_add_resource_path()` wants the tree an
|
||||
icon theme has, so the prefix is followed by `<size>/<context>`; `scalable` is the one
|
||||
that means "an SVG, use it at any size".
|
||||
The icons this app had to draw for itself, compiled into the binary so there is still
|
||||
exactly one file to run. `gtk_icon_theme_add_resource_path()` wants the tree an icon theme
|
||||
has, so the prefix is followed by `<size>/<context>`; `scalable` is the one that means
|
||||
"an SVG, use it at any size", and `apps` is where an application icon belongs.
|
||||
|
||||
Every file carries `preprocess="xml-stripblanks"`, which makes glib-compile-resources pipe
|
||||
it through xmllint. That is the only thing that catches a malformed SVG: librsvg refuses
|
||||
such a file outright, but the icon *name* still resolves, so `gtk_icon_theme_has_icon()`
|
||||
returns true and the icon simply draws nothing. No runtime GTK call can tell the two apart
|
||||
(`lookup_icon()` never returns NULL and always reports the size it was asked for), so the
|
||||
check belongs here, where a malformed SVG fails the build with a file and line number
|
||||
(plus a caret under the offending character) instead.
|
||||
-->
|
||||
<gresources>
|
||||
<gresource prefix="/org/udpscope/icons">
|
||||
<file>scalable/actions/udpscope-trigger-symbolic.svg</file>
|
||||
<file>scalable/actions/udpscope-cursors-symbolic.svg</file>
|
||||
<file>scalable/actions/udpscope-measure-symbolic.svg</file>
|
||||
<file>scalable/actions/udpscope-math-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-trigger-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-cursors-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/actions/udpscope-math-symbolic.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
|
||||
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
|
||||
</gresource>
|
||||
</gresources>
|
||||
+109
-9
@@ -3,8 +3,11 @@
|
||||
|
||||
#include "layout_store.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <dirent.h>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <sys/stat.h>
|
||||
@@ -28,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
|
||||
@@ -111,12 +106,80 @@ 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);
|
||||
return dir + "/" + sanitize(host) + "_" + std::to_string(port) + ".toml";
|
||||
}
|
||||
|
||||
std::vector<KnownSource> known_sources() {
|
||||
std::vector<KnownSource> out;
|
||||
const std::string dir = config_dir();
|
||||
DIR* d = ::opendir(dir.c_str());
|
||||
// No directory yet is an empty list, not an error: a first run has never saved anything.
|
||||
if (d == nullptr) return out;
|
||||
while (const dirent* e = ::readdir(d)) {
|
||||
const std::string name = e->d_name;
|
||||
if (name.size() <= 5 || name.compare(name.size() - 5, 5, ".toml") != 0) continue;
|
||||
const std::string stem = name.substr(0, name.size() - 5);
|
||||
// The *last* '_': `sanitize()` replaces anything unsafe in a host with one, so the host part
|
||||
// may hold them too and a first-match split would return a host cut in half.
|
||||
const std::size_t cut = stem.rfind('_');
|
||||
if (cut == std::string::npos || cut + 1 == stem.size()) continue;
|
||||
char* end = nullptr;
|
||||
const long port = std::strtol(stem.c_str() + cut + 1, &end, 10);
|
||||
if (end == nullptr || *end != '\0' || port <= 0 || port > 65535) continue;
|
||||
const std::string host = stem.substr(0, cut);
|
||||
// The name must be one `layout_path()` could have written, or the `×` -- which rebuilds it
|
||||
// through `layout_path()` -- would unlink a different session. `sanitize()` is idempotent on
|
||||
// any host that already came back out of a filename, so this is also true for hosts whose names
|
||||
// contain `_` from the sanitizer.
|
||||
if (sanitize(host) + "_" + std::to_string(port) != stem) continue;
|
||||
|
||||
LayoutState st;
|
||||
const std::string path = dir + "/" + name;
|
||||
if (!load_layout(path, st)) continue;
|
||||
|
||||
KnownSource s;
|
||||
s.host = host;
|
||||
s.port = static_cast<std::uint16_t>(port);
|
||||
s.multicast = st.multicast;
|
||||
s.interface_addr = st.interface_addr;
|
||||
s.rows = static_cast<int>(st.rows.size());
|
||||
for (const LayoutState::Row& row : st.rows) {
|
||||
for (const std::string& sig : row.signals) {
|
||||
if (!sig.empty()) ++s.signals;
|
||||
}
|
||||
}
|
||||
if (s.signals == 0) continue; // see the header: a session nobody configured
|
||||
|
||||
struct stat sb {};
|
||||
s.mtime = (::stat(path.c_str(), &sb) == 0) ? static_cast<std::int64_t>(sb.st_mtime) : 0;
|
||||
out.push_back(std::move(s));
|
||||
}
|
||||
::closedir(d);
|
||||
// Stable, so two files sharing an mtime keep the order the directory gave them rather than
|
||||
// swapping between runs.
|
||||
std::stable_sort(out.begin(), out.end(),
|
||||
[](const KnownSource& a, const KnownSource& b) { return a.mtime > b.mtime; });
|
||||
return out;
|
||||
}
|
||||
|
||||
bool forget_source(const KnownSource& src) {
|
||||
// Through `layout_path()` rather than by rebuilding the name here: one expression of the
|
||||
// scheme. `src.host` came back out of a filename, so it is already sanitized, and `sanitize()`
|
||||
// keeps alphanumerics, '.', '-' and '_' -- it is idempotent, so the round trip is exact.
|
||||
return std::remove(layout_path(src.host, src.port).c_str()) == 0;
|
||||
}
|
||||
|
||||
std::string math_node_text(const MathNode& node) {
|
||||
// The op is written as its index through `math_op_at()`, not as the raw enum value: that accessor
|
||||
// is the contract the dialog, the engine and this file share, so the format is tied to it rather
|
||||
@@ -176,13 +239,14 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
st.window_s = 0.0;
|
||||
st.rows.clear();
|
||||
st.calibration.clear();
|
||||
st.colors.clear();
|
||||
st.enabled.clear();
|
||||
st.math.clear();
|
||||
st.measures.clear();
|
||||
|
||||
std::string line;
|
||||
// What the last `[[...]]` header opened, so keys land in the right record.
|
||||
enum class Section { Top, Plot, Calibration, Math, Measure } section = Section::Top;
|
||||
enum class Section { Top, Plot, Calibration, Color, Math, Measure } section = Section::Top;
|
||||
int plot_count = 0;
|
||||
bool plot_section_seen = false;
|
||||
while (std::getline(in, line)) {
|
||||
@@ -196,6 +260,9 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
} else if (t == "[[calibration]]") {
|
||||
section = Section::Calibration;
|
||||
st.calibration.emplace_back();
|
||||
} else if (t == "[[color]]") {
|
||||
section = Section::Color;
|
||||
st.colors.emplace_back();
|
||||
} else if (t == "[[math]]") {
|
||||
section = Section::Math;
|
||||
st.math.emplace_back();
|
||||
@@ -242,6 +309,10 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
plot_count = std::max(0, std::stoi(value));
|
||||
} catch (...) {
|
||||
}
|
||||
} else if (key == "multicast") {
|
||||
st.multicast = strip_quote(value);
|
||||
} else if (key == "interface") {
|
||||
st.interface_addr = strip_quote(value);
|
||||
}
|
||||
} else if (section == Section::Plot && !st.rows.empty()) {
|
||||
if (key == "signals") {
|
||||
@@ -311,6 +382,23 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
} else if (section == Section::Color && !st.colors.empty()) {
|
||||
if (key == "name") {
|
||||
st.colors.back().name = strip_quote(value);
|
||||
} else if (key == "rgb") {
|
||||
// `"#RRGGBB"`, so the file says what it means to a reader hand-editing it -- a bare decimal
|
||||
// would be the one number in this file nobody could check by eye. Anything unparseable
|
||||
// leaves the entry at 0, and `apply_layout()` drops an entry with no name anyway.
|
||||
const std::string hex = strip_quote(value);
|
||||
const std::size_t at = hex.find('#');
|
||||
try {
|
||||
st.colors.back().rgb =
|
||||
static_cast<std::uint32_t>(std::stoul(
|
||||
at == std::string::npos ? hex : hex.substr(at + 1), nullptr, 16)) &
|
||||
0x00FFFFFFu;
|
||||
} catch (...) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!plot_section_seen && plot_count > 0) {
|
||||
@@ -325,6 +413,13 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
||||
o << "# udpscope session layout (written by the app; safe to edit)\n";
|
||||
if (state.window_s > 0.0) o << "window_s = " << state.window_s << "\n";
|
||||
o << "plots = " << state.rows.size() << "\n";
|
||||
// Top level, before any table. A bare key written after a `[[table]]` header belongs to that
|
||||
// table in TOML, which is exactly how `enabled` was once parsed into the last `[[calibration]]`
|
||||
// record and silently lost.
|
||||
if (!state.multicast.empty()) o << "multicast = " << toml_escape(state.multicast) << "\n";
|
||||
if (!state.interface_addr.empty()) {
|
||||
o << "interface = " << toml_escape(state.interface_addr) << "\n";
|
||||
}
|
||||
// Top level, before any table. Emitted after the `[[calibration]]` tables it belonged to the last
|
||||
// of them -- a bare key after a header is that table's key in TOML -- so the parser, correctly,
|
||||
// never saw it and a session's enable state was silently lost.
|
||||
@@ -348,6 +443,11 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
||||
o << "\n[[calibration]]\nname = " << toml_escape(cal.name) << "\ngain = " << cal.gain
|
||||
<< "\noffset = " << cal.offset << "\n";
|
||||
}
|
||||
for (const auto& color : state.colors) {
|
||||
char rgb[16];
|
||||
std::snprintf(rgb, sizeof(rgb), "\"#%06X\"", color.rgb & 0x00FFFFFFu);
|
||||
o << "\n[[color]]\nname = " << toml_escape(color.name) << "\nrgb = " << rgb << "\n";
|
||||
}
|
||||
for (const MathChannel& m : state.math) {
|
||||
o << "\n[[math]]\nname = " << toml_escape(m.name) << "\nunit = " << toml_escape(m.unit)
|
||||
<< "\nenabled = " << (m.enabled ? "true" : "false") << "\noutput = " << m.output
|
||||
|
||||
@@ -32,11 +32,26 @@ struct LayoutState {
|
||||
};
|
||||
std::vector<Calibration> calibration;
|
||||
|
||||
/// The trace colours the user chose, `0xRRGGBB`. Only the *overrides*: a signal with no entry
|
||||
/// takes the positional palette entry, which is what keeps a stream's new signals coloured
|
||||
/// without a session having to name them. Saved by name like every identity here.
|
||||
struct Color {
|
||||
std::string name;
|
||||
std::uint32_t rgb = 0;
|
||||
};
|
||||
std::vector<Color> colors;
|
||||
|
||||
/// Signals the session had enabled. Enabling is what starts acquisition, so this is what makes a
|
||||
/// reopened scope look like the one that was closed -- and it is saved by *name*, like every other
|
||||
/// identity here, because a CONFIG renumbers the channels.
|
||||
std::vector<std::string> enabled;
|
||||
|
||||
/// How this source was reached, so the startup chooser can reconnect to it exactly. The file is
|
||||
/// named `<host>_<port>.toml` and that name can say neither of these. Empty `multicast` is
|
||||
/// unicast; empty `interface_addr` is the default route.
|
||||
std::string multicast;
|
||||
std::string interface_addr;
|
||||
|
||||
/// The derived channels, graphs and all: the editor's work is part of the session, not of the
|
||||
/// process. A graph is stored as one string per node so the file stays in this store's TOML
|
||||
/// subset (tables, string arrays, scalars) rather than growing a nested-table parser for it; see
|
||||
@@ -47,12 +62,38 @@ struct LayoutState {
|
||||
std::vector<Measure> measures;
|
||||
};
|
||||
|
||||
/// A data source udpscope has a stored session for: one row of the startup chooser.
|
||||
struct KnownSource {
|
||||
std::string host; ///< the CONFIG server, even for a multicast session
|
||||
std::uint16_t port = 0;
|
||||
std::string multicast; ///< empty = unicast
|
||||
std::string interface_addr; ///< empty = the default route
|
||||
int rows = 0; ///< plots the session had
|
||||
int signals = 0; ///< named slots across those plots
|
||||
std::int64_t mtime = 0; ///< orders the list; never shown
|
||||
};
|
||||
|
||||
/// Every session file that names at least one signal, newest first.
|
||||
///
|
||||
/// The filter is what makes the list usable: a development machine accumulates one session file
|
||||
/// per probe run on a throwaway port -- 140 of them when this was written -- and every one holds
|
||||
/// `signals = ["", "", "", ""]`. A listed source is one somebody actually configured.
|
||||
std::vector<KnownSource> known_sources();
|
||||
|
||||
/// Deletes one source's session file; the chooser's `x`. False means the file was already gone or
|
||||
/// could not be removed, which costs the caller nothing -- there is no state to roll back.
|
||||
bool forget_source(const KnownSource& src);
|
||||
|
||||
/// One `MathNode` as `kind|source|in_a|in_b|gain|offset|fc|q|lo|hi|x|y`, and back. The kind is the
|
||||
/// op's *index* into `math_op_at()`, which is why the enum's order is a stored format and not just
|
||||
/// an implementation detail: reordering it renames every saved graph's operations.
|
||||
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);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include "config.hpp"
|
||||
#include "model.hpp"
|
||||
#include "plot_host.hpp"
|
||||
#include "source_dialog.hpp"
|
||||
#include "ui.hpp"
|
||||
|
||||
#include <cstdio>
|
||||
@@ -19,6 +20,10 @@ int main(int argc, char** argv) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// No `--server` means the user has not said which source to open, so ask. Before the model:
|
||||
// declining must cost no buffer and no socket, and nothing below has to cope with "no source".
|
||||
if (!cfg.server_specified && !udpscope::choose_source(cfg)) return 0;
|
||||
|
||||
udpscope::ScopeModel model(cfg);
|
||||
udpscope::RowSet rows;
|
||||
rows.set_row_count(cfg.plots);
|
||||
|
||||
+16
-4
@@ -106,6 +106,13 @@ MathOp math_op_at(int index) {
|
||||
}
|
||||
}
|
||||
|
||||
int math_op_index(MathOp op) {
|
||||
for (int i = 0; i < math_op_count(); ++i) {
|
||||
if (math_op_at(i) == op) return i;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int math_op_arity(MathOp op) {
|
||||
switch (op) {
|
||||
case MathOp::Source: return 0;
|
||||
@@ -215,15 +222,15 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
|
||||
return rts[static_cast<std::size_t>(out)].value;
|
||||
}
|
||||
|
||||
bool math_channel_valid(const MathChannel& channel, std::string* why) {
|
||||
const std::size_t n = channel.nodes.size();
|
||||
bool math_channel_valid_nodes(const std::vector<MathNode>& nodes, int output, std::string* why) {
|
||||
const std::size_t n = nodes.size();
|
||||
const auto fail = [&](const std::string& text) {
|
||||
if (why != nullptr) *why = text;
|
||||
return false;
|
||||
};
|
||||
if (n == 0) return fail("empty graph");
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const MathNode& node = channel.nodes[i];
|
||||
const MathNode& node = nodes[i];
|
||||
if (node.op == MathOp::Source && node.source.empty()) {
|
||||
return fail("node " + std::to_string(i + 1) + ": no channel chosen");
|
||||
}
|
||||
@@ -238,10 +245,15 @@ bool math_channel_valid(const MathChannel& channel, std::string* why) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (channel.output < 0 || static_cast<std::size_t>(channel.output) >= n) {
|
||||
if (output < 0 || static_cast<std::size_t>(output) >= n) {
|
||||
return fail("no output chosen");
|
||||
}
|
||||
if (why != nullptr) why->clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool math_channel_valid(const MathChannel& channel, std::string* why) {
|
||||
return math_channel_valid_nodes(channel.nodes, channel.output, why);
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -30,6 +30,10 @@ enum class MathOp {
|
||||
const char* math_op_name(MathOp op);
|
||||
int math_op_count();
|
||||
MathOp math_op_at(int index);
|
||||
/// An op's position in `math_op_at()`'s order. That order is the *dialog's* -- what a dropdown or a
|
||||
/// menu lists, and what a session file stores -- and it is deliberately not promised to be the
|
||||
/// enum's declaration order, so a caller that needs a position must ask rather than cast.
|
||||
int math_op_index(MathOp op);
|
||||
/// Inputs the op takes: 0 for `Source`, 1 for the unary ones, 2 for the arithmetic.
|
||||
int math_op_arity(MathOp op);
|
||||
/// Which of the per-node parameters an op actually reads, so a dialog can show only those rows --
|
||||
@@ -117,4 +121,9 @@ double math_step(const MathChannel& channel, MathRuntime& rt, const float* value
|
||||
/// input it needs connected to a node that exists (with `Source` nodes naming something).
|
||||
bool math_channel_valid(const MathChannel& channel, std::string* why = nullptr);
|
||||
|
||||
/// The same check over a bare node list and output index, for a caller that has a graph but not yet
|
||||
/// a `MathChannel` -- the editor, and the headless probe that drives it.
|
||||
bool math_channel_valid_nodes(const std::vector<MathNode>& nodes, int output,
|
||||
std::string* why = nullptr);
|
||||
|
||||
} // namespace udpscope
|
||||
+63
-15
@@ -28,8 +28,27 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
|
||||
acc.open[e] = true;
|
||||
}
|
||||
|
||||
/// The hysteresis band's half-width, as a share of the window's peak-to-peak. Measured rather than
|
||||
/// chosen: 5 % reads the mock's 0.1 interferer correctly and fails at 0.2 (`period=0.075` for a
|
||||
/// true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third of the waveform -- and
|
||||
/// to white noise at sigma=0.05. Above that the band starts eating a real waveform's own extremes
|
||||
/// on anything less square than a sine.
|
||||
constexpr double kHysteresisFrac = 0.10;
|
||||
|
||||
} // namespace
|
||||
|
||||
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
|
||||
const double span = s.hi - s.lo;
|
||||
acc.level = s.lo + pct * span;
|
||||
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
|
||||
// above it, so a fixed 10 % band would put `level + h` *above every sample*: the walk then never
|
||||
// decides the upper side and the measurement reads nothing at all, which is strictly worse than
|
||||
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
|
||||
// fixed band, `enters=4 period=0.200000` with the clamp.
|
||||
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
|
||||
acc.hysteresis = std::max(0.0, std::min(kHysteresisFrac * span, 0.5 * room));
|
||||
}
|
||||
|
||||
void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
|
||||
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
|
||||
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
|
||||
@@ -130,17 +149,27 @@ void measure_accumulate(const MeasureChunk& chunk,
|
||||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
||||
const double v = static_cast<double>(chunk.value[i]);
|
||||
if (!std::isfinite(v)) continue;
|
||||
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
|
||||
// below the threshold, and a band here would move a documented exact reading.
|
||||
const int s = v > acc.level ? 1 : 0;
|
||||
acc.sum[static_cast<std::size_t>(s)] += v;
|
||||
++acc.count[static_cast<std::size_t>(s)];
|
||||
// A sample *at* the level is on neither side, so it decides no crossing and the walk keeps
|
||||
// the side it last decided. That is what stops a signal which merely touches the level from
|
||||
// counting every sample as an edge.
|
||||
if (v == acc.level) continue;
|
||||
if (acc.have_side && s != (acc.side ? 1 : 0)) {
|
||||
enter_side(acc, static_cast<std::size_t>(s), time_of(chunk.offset + i));
|
||||
// The *edge* decision reads the band instead. A sample inside it is on neither side, so it
|
||||
// decides no crossing and the walk keeps the side it last decided -- which is what stops a
|
||||
// signal that merely wobbles about the level from counting every wobble as an edge. With
|
||||
// `hysteresis == 0` this is the single-level test, `v == level` skip included.
|
||||
int d;
|
||||
if (v > acc.level + acc.hysteresis) {
|
||||
d = 1;
|
||||
} else if (v < acc.level - acc.hysteresis) {
|
||||
d = 0;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
acc.side = s != 0;
|
||||
if (acc.have_side && d != (acc.side ? 1 : 0)) {
|
||||
enter_side(acc, static_cast<std::size_t>(d), time_of(chunk.offset + i));
|
||||
}
|
||||
acc.side = d != 0;
|
||||
acc.have_side = true;
|
||||
}
|
||||
}
|
||||
@@ -153,21 +182,40 @@ bool measure_accumulate_bucket(const MeasureBucket& k,
|
||||
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
|
||||
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
|
||||
if (k.count != k.span) return false;
|
||||
// Strictly on one side, so every sample decides the same side and none is *at* the level --
|
||||
// which is what makes this exact rather than an approximation of the walk.
|
||||
// The *sums* need only the bare level: strictly on one side of it, so every sample decides the
|
||||
// same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
|
||||
const bool above = k.lo > acc.level;
|
||||
const bool below = k.hi < acc.level;
|
||||
if (!above && !below) return false;
|
||||
const std::size_t e = above ? 1u : 0u;
|
||||
// The band is deliberately *not* folded into the test above. Tightening the whole condition to
|
||||
// `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
|
||||
// of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
|
||||
// passes through -- which hands the raw walk a share of the window rather than a bucket or two per
|
||||
// crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
|
||||
// Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
|
||||
// whose position is inside the bucket is refused.
|
||||
const double top = acc.level + acc.hysteresis;
|
||||
const double bot = acc.level - acc.hysteresis;
|
||||
// Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
|
||||
// raw sample inside it decides none, so the walk state is left alone.
|
||||
const bool reaches = above ? (k.hi > top) : (k.lo < bot);
|
||||
const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
|
||||
// A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
|
||||
// inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
|
||||
// the bucket's first sample, exactly, which is where the raw walk would have found it.
|
||||
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
|
||||
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
|
||||
// which adds the same samples again. Sums added above the refusal double-counted every opened
|
||||
// bucket, which read as `top` 0.614304 on a unit sine against the 2/pi it must be.
|
||||
if (change && !fully_outside) return false;
|
||||
acc.sum[e] += k.sum;
|
||||
acc.count[e] += k.count;
|
||||
if (acc.have_side && e != (acc.side ? 1u : 0u)) {
|
||||
// The edge is the bucket's *first* sample: everything before it was on the other side and
|
||||
// everything in the bucket is on this one, so that is where the raw walk would have found it.
|
||||
enter_side(acc, e, time_of(k.offset));
|
||||
if (change) enter_side(acc, e, time_of(k.offset));
|
||||
if (reaches) {
|
||||
acc.side = e != 0;
|
||||
acc.have_side = true;
|
||||
}
|
||||
acc.side = e != 0;
|
||||
acc.have_side = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
+30
-4
@@ -152,6 +152,20 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu
|
||||
/// must adding a second measurement of the same type at the same level.
|
||||
struct MeasureLevel {
|
||||
double level = 0.0;
|
||||
/// Half-width of the hysteresis band about `level`: a crossing is counted only once the signal
|
||||
/// has passed `level + hysteresis` going up or `level - hysteresis` going down, and a sample
|
||||
/// inside the band decides nothing. It is a Schmitt trigger, and it is what makes an edge count
|
||||
/// a count of the *waveform's* cycles rather than of its noise: a single level is recrossed
|
||||
/// several times per true crossing by any interferer faster than the fundamental, because near
|
||||
/// the mid level the fundamental's own slope is at its smallest. Measured on a 5 Hz sine with a
|
||||
/// 500 Hz interferer at a tenth of the amplitude: 9 true crossings counted as 79, reading
|
||||
/// `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz. With the band, `period=0.200188`.
|
||||
///
|
||||
/// It moves the *edge decision* only. The `sum`/`count` split below stays at the bare `level`,
|
||||
/// because that is what `Top`/`Bottom` are -- the mean of the samples above/below the threshold --
|
||||
/// and a band there would change a documented exact reading (`top` on a unit sine is 2/pi).
|
||||
/// `measure_set_level()` is what sizes it; zero reduces the walk exactly to a single level.
|
||||
double hysteresis = 0.0;
|
||||
/// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what
|
||||
/// `bottom` has always reported), index 1 the side above it.
|
||||
double sum[2] = {0.0, 0.0};
|
||||
@@ -170,6 +184,14 @@ struct MeasureLevel {
|
||||
bool open[2] = {false, false};
|
||||
};
|
||||
|
||||
/// Fill a level accumulator's `level` and `hysteresis` from the window's extremes and the share
|
||||
/// `measure_level_pct()` gave. The one place the band is sized, so every caller gets the same rule:
|
||||
/// a tenth of the window's peak-to-peak, *clamped* to half the room between the level and the
|
||||
/// nearer extreme. The clamp is what keeps a threshold near an extreme usable -- `PulseDuration`
|
||||
/// lets the user put one at 90 %, and a fixed band there puts the upper threshold above every
|
||||
/// sample in the window, which reads as nothing measured at all rather than as a noisy reading.
|
||||
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct);
|
||||
|
||||
/// Pass two: one chunk into every level at once. The usual case is a single level -- every
|
||||
/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of
|
||||
/// measurements on one signal costs two walks, not two per measurement.
|
||||
@@ -182,10 +204,14 @@ void measure_accumulate(const MeasureChunk& chunk,
|
||||
/// fold in whole and the walk's side is decided by the bucket rather than by 64 samples. An edge
|
||||
/// *into* such a bucket lands on its first sample, exactly, which is why that is the stamp taken.
|
||||
///
|
||||
/// False means the bucket straddles the level -- or holds a non-finite sample, which would make
|
||||
/// the edge's position a guess -- and the caller must feed its raw samples to
|
||||
/// `measure_accumulate()` for this level instead. That is the only place a measurement still
|
||||
/// reads raw samples in bulk, and on a clean waveform it is a bucket per crossing.
|
||||
/// False means the caller must feed the bucket's raw samples to `measure_accumulate()` for this
|
||||
/// level instead. That is the only place a measurement still reads raw samples in bulk, and on a
|
||||
/// clean waveform it is a bucket or two per crossing. Three things make it happen: the bucket
|
||||
/// straddles the level (so the sums cannot be binned); it holds a non-finite sample, which would
|
||||
/// make the edge's position a guess; or it is where the signal passes `level +/- hysteresis` and the
|
||||
/// *position* of that crossing is inside the bucket. The band is deliberately not folded into the
|
||||
/// first test -- see the comment in the implementation for why refusing every bucket inside the band
|
||||
/// costs a share of the window rather than a bucket.
|
||||
bool measure_accumulate_bucket(const MeasureBucket& bucket,
|
||||
const std::function<double(std::size_t)>& time_of,
|
||||
MeasureLevel& level);
|
||||
|
||||
+69
-33
@@ -42,12 +42,6 @@ constexpr std::uint64_t kMathCostSample = 1024;
|
||||
|
||||
} // namespace
|
||||
|
||||
u64 Partition::total_samples() const noexcept {
|
||||
u64 total = time_capacity;
|
||||
for (u64 c : value_capacity) total += c;
|
||||
return total;
|
||||
}
|
||||
|
||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
||||
double target_span_s) {
|
||||
Partition p;
|
||||
@@ -76,11 +70,14 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
||||
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
|
||||
|
||||
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
|
||||
const u64 budget = budget_bytes / sizeof(f32);
|
||||
double weight = 1.0; // the time slice itself
|
||||
for (u64 s : p.stride) weight += 1.0 / static_cast<double>(s);
|
||||
// Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32
|
||||
// (see `BufferStore::time()`), so a time slot costs twice a value sample and the two
|
||||
// slices are not interchangeable. Counting both as one f32 is what would make
|
||||
// `--buffer-mb` quietly understate the time ring by its own size.
|
||||
double weight = static_cast<double>(sizeof(f64)); // the time slice itself
|
||||
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
|
||||
|
||||
const u64 slots = budget / static_cast<u64>(std::llround(weight));
|
||||
const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
|
||||
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
|
||||
if (time_capacity == 0) {
|
||||
throw std::runtime_error("buffer too small for one time-base sample per channel");
|
||||
@@ -102,11 +99,13 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
||||
return std::max<u64>(pow2_floor(ticks / stride), 1);
|
||||
};
|
||||
const auto bytes_for = [&](u64 ticks) {
|
||||
u64 total = ticks;
|
||||
for (u64 s : p.stride) total += capacity_for(ticks, s);
|
||||
return total * sizeof(f32);
|
||||
u64 total = ticks * sizeof(f64);
|
||||
for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
|
||||
return total;
|
||||
};
|
||||
while (time_capacity * 2 <= budget && bytes_for(time_capacity * 2) <= budget_bytes) {
|
||||
// `bytes_for` bounds the doubling on its own: it is at least `2 * ticks * sizeof(f64)`,
|
||||
// so a pass means `2 * ticks` is well inside the budget and cannot overflow.
|
||||
while (bytes_for(time_capacity * 2) <= budget_bytes) {
|
||||
time_capacity *= 2;
|
||||
}
|
||||
p.time_capacity = time_capacity;
|
||||
@@ -138,7 +137,7 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
||||
|
||||
BufferStore::BufferStore(const Partition& part, f64 span_s)
|
||||
: part_(part),
|
||||
time_(std::make_shared<plot::data::RingBuffer<f32>>(part.time_capacity)) {
|
||||
time_(std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity)) {
|
||||
(void)span_s;
|
||||
const std::size_t n = part.value_capacity.size();
|
||||
values_.reserve(n);
|
||||
@@ -452,7 +451,7 @@ BufferStore& ScopeModel::live() { return *store_; }
|
||||
|
||||
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
|
||||
BufferStore& src = live();
|
||||
plot::data::RingBuffer<f32>& tsrc = src.time();
|
||||
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||
|
||||
// The slice is one absolute index range, found in the time ring and then shared by
|
||||
// every value ring. Sharing it is what makes a cut's stamps and each of its channels
|
||||
@@ -460,16 +459,15 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
|
||||
// truncations that could disagree.
|
||||
const u64 tbase = tsrc.produced() - tsrc.size();
|
||||
const u64 tend = tsrc.produced();
|
||||
// The bounds are rounded to the ring's own `f32` before they are compared against
|
||||
// it. The window arrives in f64 and the stamps are stored as f32, so an f64
|
||||
// comparison decides a sample that sits exactly on a bound by which way its stamp
|
||||
// happened to round -- the same nominal window then keeps or drops its first sample
|
||||
// depending on how large the stream time is (`f32(38.001) < 38.001` but
|
||||
// `f32(18.001) > 18.001`). One sample is invisible at 10 kS/s and a third of the
|
||||
// capture at 1 S/s. Comparing in the stored type makes a bound that lands on a
|
||||
// sample include it, always.
|
||||
const f32 t_lo = static_cast<f32>(lo_s);
|
||||
const f32 t_hi = static_cast<f32>(hi_s);
|
||||
// The bounds are compared in the ring's own type, which is the `f64` they arrive in,
|
||||
// so a bound that lands exactly on a sample includes it. This used to need rounding
|
||||
// *down* to the stored f32 first: an f64 comparison against an f32 stamp decided such
|
||||
// a sample by which way its stamp happened to round, and the same nominal window kept
|
||||
// or dropped its first sample depending on the magnitude of the stream time
|
||||
// (`f32(38.001) < 38.001` but `f32(18.001) > 18.001`). The ring is f64 now and the
|
||||
// question does not arise.
|
||||
const f64 t_lo = lo_s;
|
||||
const f64 t_hi = hi_s;
|
||||
|
||||
// Stream time is non-decreasing, so the bounds are a binary search. Searching the
|
||||
// stamps is the whole point: the window is in seconds and this ring is the only
|
||||
@@ -496,7 +494,7 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
|
||||
last = a;
|
||||
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
plot::data::RingBuffer<f32>& from = src.values(c);
|
||||
plot::data::RingBuffer<f32>& from = src.values(c); // values stay f32
|
||||
first = std::max(first, from.produced() - from.size());
|
||||
last = std::min(last, from.produced());
|
||||
}
|
||||
@@ -517,8 +515,12 @@ void ScopeModel::reset_live_history_locked() {
|
||||
}
|
||||
}
|
||||
|
||||
void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
||||
void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
|
||||
// Timed from before the lock: the wait for it is part of what freezing a view costs the thread
|
||||
// doing it, and this one is the UI's.
|
||||
const std::uint64_t t0 = now_ns();
|
||||
auto snap = std::make_shared<EventSnapshot>();
|
||||
snap->x_offset = x_offset;
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
BufferStore& src = live();
|
||||
u64 first = 0;
|
||||
@@ -527,13 +529,18 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
||||
const u64 n = last - first;
|
||||
snap->stamps.resize(static_cast<std::size_t>(n));
|
||||
if (n > 0) {
|
||||
// The stamps go in *as the ring holds them*: the stream's own seconds, which is the
|
||||
// coordinate the axis was already reading. Nothing is rebased, so a pause does not move the
|
||||
// dates under the reader the way a trigger event's edge-relative axis deliberately does.
|
||||
// The stamps come out of the ring on the stream's own clock and are rebased onto
|
||||
// `x_offset`, which is the coordinate the axis was *already* reading: a live view is
|
||||
// newest-relative, so leaving them absolute would throw the frozen picture a whole uptime
|
||||
// to the right of the axis at the instant the user pressed pause. Nothing is lost by the
|
||||
// shift -- `x_offset` is exactly what makes the stream's time recoverable, which is what
|
||||
// `stream_at_x0()` reads it for.
|
||||
src.time().copy_from(first, n, snap->stamps.data());
|
||||
for (f64& t : snap->stamps) t -= x_offset;
|
||||
}
|
||||
snap->values.resize(channels_.size());
|
||||
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||
snap->pyramids.resize(channels_.size());
|
||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||
const u64 stride = std::max<u64>(1, part_.stride[c]);
|
||||
auto& vals = snap->values[c];
|
||||
@@ -542,19 +549,46 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
||||
if (take > 0) src.values(c).copy_from(first + n - take, take, vals.data());
|
||||
f32 lo = vals.empty() ? 0.0f : vals.front();
|
||||
f32 hi = lo;
|
||||
// The LOD index, folded into the bounds pass exactly as `publish_event()` folds it, and for
|
||||
// the same reason: a snapshot that answers `pyramid()` with an empty one has `level_for()`
|
||||
// descend to level 0, so the frozen view is re-gathered from every raw sample it holds on
|
||||
// every frame it is on screen. A pause is not a cheaper case than a capture -- it freezes the
|
||||
// *same* rings -- and leaving it out was visible twice over. The cost: `frame path:` read
|
||||
// `raw=21 lod=118` over a 1.6 s pause against `raw=1 lod=170` for the same run live, with the
|
||||
// draw mean 12.34 -> 18.18 ms. And the *look* changed, which is the half a cost figure does
|
||||
// not show: `stage_trace_density()` grades a column against its own peak from a `1/height`
|
||||
// deposit per segment, so a column fed ~660 raw inter-sample segments accumulates a far
|
||||
// spikier distribution -- and a far larger peak to normalise against -- than one fed the one
|
||||
// or two bucket envelopes the pyramid gives. The paused trace read as a hard bright core with
|
||||
// its dwell tail dimmed out, i.e. the trace changed appearance on pause.
|
||||
//
|
||||
// It is not free, and the trade is one-off against per-frame: building it took the pause
|
||||
// publish from 6.15 ms to 22.31 ms (`publish cost:`), which blocks the *producer* -- against
|
||||
// ~5 ms a frame of draw for as long as the pause lasts, which at ~60 fps a 1.6 s pause repays
|
||||
// thirty times over. The producer absorbs the stall: `gaps=0` with and without a pause, the
|
||||
// paused run having published in 86 ms.
|
||||
auto& pyr = snap->pyramids[c];
|
||||
pyr.reserve(take);
|
||||
for (f32 v : vals) {
|
||||
lo = std::min(lo, v);
|
||||
hi = std::max(hi, v);
|
||||
pyr.append(v);
|
||||
}
|
||||
snap->y[c] = plot::Range{lo, hi};
|
||||
}
|
||||
pause_view_.store(std::move(snap), std::memory_order_release);
|
||||
note_publish(publish_pause_count_, publish_pause_us_total_, publish_pause_us_worst_,
|
||||
(now_ns() - t0) / 1000u);
|
||||
}
|
||||
|
||||
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
// The caller is the producer and already holds `mutex()`, so every microsecond counted here is a
|
||||
// microsecond the UI tick is blocked for -- which is what the documented `tick` max is made of.
|
||||
const std::uint64_t t0 = now_ns();
|
||||
auto snap = std::make_shared<EventSnapshot>();
|
||||
snap->x_offset = edge; // the stream time x = 0 means, for everything that has to cross back
|
||||
BufferStore& src = live();
|
||||
plot::data::RingBuffer<f32>& tsrc = src.time();
|
||||
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||
|
||||
u64 first = 0, last = 0;
|
||||
live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last);
|
||||
@@ -566,7 +600,7 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
// Edge-relative from here on: the frozen event reads [-pre, +post] with the
|
||||
// edge at exactly 0, with no axis-level shift any other code path could
|
||||
// disagree with.
|
||||
for (auto& t : snap->stamps) t -= static_cast<f32>(edge);
|
||||
for (auto& t : snap->stamps) t -= edge;
|
||||
}
|
||||
|
||||
snap->values.resize(channels_.size());
|
||||
@@ -601,6 +635,8 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||
// Bumped after the snapshot is in place, so a consumer that sees the new sequence
|
||||
// number sees the snapshot it counts.
|
||||
event_seq_.fetch_add(1, std::memory_order_release);
|
||||
note_publish(publish_event_count_, publish_event_us_total_, publish_event_us_worst_,
|
||||
(now_ns() - t0) / 1000u);
|
||||
}
|
||||
|
||||
void BufferStore::set_epoch(f64 epoch) noexcept {
|
||||
|
||||
+88
-17
@@ -93,8 +93,6 @@ struct Partition {
|
||||
double base_dt = 1.0; ///< seconds per base tick
|
||||
double span_s = 0.0; ///< stream seconds one buffer covers
|
||||
|
||||
u64 total_samples() const noexcept;
|
||||
u64 total_bytes() const noexcept { return total_samples() * sizeof(f32); }
|
||||
/// Seconds between two time-base slots (the sample interval of every channel).
|
||||
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
|
||||
/// The rate samples are actually stored at, in samples per second.
|
||||
@@ -125,8 +123,15 @@ class BufferStore {
|
||||
/// axis read seconds from the trigger event.
|
||||
void set_epoch(f64 epoch) noexcept;
|
||||
|
||||
plot::data::RingBuffer<f32>& time() noexcept { return *time_; }
|
||||
const plot::data::RingBuffer<f32>& time() const noexcept { return *time_; }
|
||||
/// The shared clock, in **f64**. The values are f32 and the clock is not: a stamp is
|
||||
/// `stream_time - epoch` and the epoch is fixed at the first sample ever seen, so the
|
||||
/// magnitude grows without bound over a run's life while the interval that has to be
|
||||
/// resolved stays the sample period. f32 has 24 bits of mantissa, so its ULP at 32 s is
|
||||
/// 3.8 us -- four consecutive samples of a 1 MS/s stream then share one stamp, and at
|
||||
/// 600 s uptime 61 of them do. A value's precision is a property of the signal and 24
|
||||
/// bits is plenty; a *timestamp's* is a property of how long the scope has been running.
|
||||
plot::data::RingBuffer<f64>& time() noexcept { return *time_; }
|
||||
const plot::data::RingBuffer<f64>& time() const noexcept { return *time_; }
|
||||
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
|
||||
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
|
||||
/// Shared handle to a channel view: the caller keeps the storage alive even if
|
||||
@@ -141,7 +146,7 @@ class BufferStore {
|
||||
|
||||
private:
|
||||
Partition part_;
|
||||
std::shared_ptr<plot::data::RingBuffer<f32>> time_;
|
||||
std::shared_ptr<plot::data::RingBuffer<f64>> time_;
|
||||
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
|
||||
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
|
||||
};
|
||||
@@ -219,6 +224,37 @@ class ScopeModel {
|
||||
std::uint64_t math_cost_ns() const noexcept {
|
||||
return math_cost_ns_.load(std::memory_order_relaxed);
|
||||
}
|
||||
/// What freezing a view costs, and *which thread it costs it to*. The two publishes are stalls on
|
||||
/// opposite sides of `mutex()` and so are counted apart: `publish_event()` runs on the **producer**
|
||||
/// with the lock already held, so its cost lands on the UI as a long tick (the documented `tick`
|
||||
/// max); `publish_pause_view()` runs on the **UI** thread and takes the lock, so its cost lands on
|
||||
/// the producer and can only surface as `gaps`. Neither is attributable in a frame mean, which is
|
||||
/// why they are counted rather than inferred. Each counter is written by one thread and read by the
|
||||
/// dump, so a relaxed store of a value already in hand is enough.
|
||||
///
|
||||
/// This is the number to judge a second live ring against: freezing a whole `BufferStore` would
|
||||
/// make both figures zero, since a `ChannelBuffer` maintains its pyramid incrementally and there
|
||||
/// would be nothing to copy -- but it costs a second budget's worth of memory and a second
|
||||
/// per-sample write on the producer, so it is worth paying only if the worst figures here are
|
||||
/// large enough to matter.
|
||||
std::uint64_t publish_event_count() const noexcept {
|
||||
return publish_event_count_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::uint64_t publish_event_us_total() const noexcept {
|
||||
return publish_event_us_total_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::uint64_t publish_event_us_worst() const noexcept {
|
||||
return publish_event_us_worst_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::uint64_t publish_pause_count() const noexcept {
|
||||
return publish_pause_count_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::uint64_t publish_pause_us_total() const noexcept {
|
||||
return publish_pause_us_total_.load(std::memory_order_relaxed);
|
||||
}
|
||||
std::uint64_t publish_pause_us_worst() const noexcept {
|
||||
return publish_pause_us_worst_.load(std::memory_order_relaxed);
|
||||
}
|
||||
const std::vector<SignalInfo>& signals() const noexcept { return signals_; }
|
||||
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
|
||||
|
||||
@@ -278,23 +314,24 @@ class ScopeModel {
|
||||
/// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds,
|
||||
/// clamped to what the time ring and *every* value ring still hold. One range for the
|
||||
/// time ring and all the channels, which is what keeps a cut index-aligned by
|
||||
/// construction; the bounds are compared in the ring's own `f32`, so a bound that
|
||||
/// lands on a sample includes it whatever the magnitude of the stream time.
|
||||
/// construction; the bounds are compared in the ring's own type, which is now the
|
||||
/// `f64` they arrive in, so a bound that lands on a sample includes it exactly.
|
||||
///
|
||||
/// Caller holds `mutex()` (the acquisition thread already does when it latches).
|
||||
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
|
||||
/// The frozen view taken when the user paused, or null while running. A pause is a
|
||||
/// snapshot of the *visible window* in exactly the sense a trigger event is -- an immutable
|
||||
/// series the plot binds to -- and the difference is the anchoring: an event's stamps are
|
||||
/// edge-relative, a pause's are the stream's own, so the axis does not move when the pause
|
||||
/// begins and the reader keeps the times they were already reading.
|
||||
/// series the plot binds to -- and both are anchored the same way: the stamps are the
|
||||
/// coordinates the axis was already reading, with `x_offset` recording which instant that
|
||||
/// x = 0 was. So the axis does not move when the pause begins.
|
||||
std::shared_ptr<const EventSnapshot> pause_view() const noexcept {
|
||||
return pause_view_.load(std::memory_order_acquire);
|
||||
}
|
||||
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, the display epoch already
|
||||
/// removed) as the pause view. The acquisition thread keeps writing, so this is a copy: what
|
||||
/// is frozen is the picture, not the data.
|
||||
void publish_pause_view(double lo_s, double hi_s);
|
||||
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, i.e. the display window put
|
||||
/// back on the stream's clock) as the pause view, rebased onto `x_offset` so its stamps are
|
||||
/// the x the axis is already showing. The acquisition thread keeps writing, so this is a
|
||||
/// copy: what is frozen is the picture, not the data.
|
||||
void publish_pause_view(double lo_s, double hi_s, double x_offset);
|
||||
void clear_pause_view() noexcept { pause_view_.store(nullptr, std::memory_order_release); }
|
||||
|
||||
/// Whether the user paused acquisition. Lives here rather than on `Acquisition` because the
|
||||
@@ -373,12 +410,30 @@ class ScopeModel {
|
||||
return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting;
|
||||
}
|
||||
|
||||
/// The x origin the plot uses: 0 for "seconds since the stream started", or minus
|
||||
/// the trigger instant while in trigger mode, so the axis reads seconds from the
|
||||
/// event. Applied to the live store's channels.
|
||||
/// The offset the **live** channels report their x with: `display_x = stream_t + epoch`.
|
||||
/// Minus the newest stored sample's stamp while streaming, so the axis reads
|
||||
/// `[-window, 0]` and the newest data is at 0; minus the trigger instant while a capture
|
||||
/// is filling, so the axis reads seconds from the event. It is 0 while a *snapshot* is on
|
||||
/// screen (a held event, a paused view), because then the live channels are bound to
|
||||
/// nothing and the snapshot's own `x_offset` is what says which clock x = 0 is --
|
||||
/// `stream_at_x0()` is the accessor that does not have to know which of the two it is.
|
||||
f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); }
|
||||
void set_display_epoch(f64 e) noexcept;
|
||||
|
||||
/// The stream time of the display's x = 0, so `stream_t = display_x + stream_at_x0()` and
|
||||
/// `display_x = stream_t - stream_at_x0()` in **every** display mode.
|
||||
///
|
||||
/// One accessor for the two places the offset can live: a snapshot carries its own
|
||||
/// (`EventSnapshot::x_offset`, frozen with the picture), and a live view carries it as
|
||||
/// minus the display epoch. Everything that has to cross between the two clocks goes
|
||||
/// through here -- the export's absolute column, and the pause handler putting a display
|
||||
/// window back on the ring to cut it -- rather than picking the branch itself and getting
|
||||
/// one of the four modes wrong.
|
||||
f64 stream_at_x0() const noexcept {
|
||||
if (std::shared_ptr<const EventSnapshot> snap = display_snapshot()) return snap->x_offset;
|
||||
return -display_epoch();
|
||||
}
|
||||
|
||||
// --- stream state ---------------------------------------------------------
|
||||
StreamStats& stats() noexcept { return stats_; }
|
||||
const StreamStats& stats() const noexcept { return stats_; }
|
||||
@@ -467,6 +522,22 @@ class ScopeModel {
|
||||
std::atomic<std::uint64_t> math_slots_{0};
|
||||
std::atomic<std::uint64_t> math_timed_{0};
|
||||
std::atomic<std::uint64_t> math_cost_ns_{0};
|
||||
/// See `publish_event_count()`. Single-writer each: the first three are the producer's, the last
|
||||
/// three the UI thread's.
|
||||
std::atomic<std::uint64_t> publish_event_count_{0};
|
||||
std::atomic<std::uint64_t> publish_event_us_total_{0};
|
||||
std::atomic<std::uint64_t> publish_event_us_worst_{0};
|
||||
std::atomic<std::uint64_t> publish_pause_count_{0};
|
||||
std::atomic<std::uint64_t> publish_pause_us_total_{0};
|
||||
std::atomic<std::uint64_t> publish_pause_us_worst_{0};
|
||||
/// Fold one publish's elapsed microseconds into a counter triple. Called by the one thread that
|
||||
/// owns the triple, so the load/store pairs need no read-modify-write.
|
||||
static void note_publish(std::atomic<std::uint64_t>& count, std::atomic<std::uint64_t>& total,
|
||||
std::atomic<std::uint64_t>& worst, std::uint64_t us) noexcept {
|
||||
count.store(count.load(std::memory_order_relaxed) + 1, std::memory_order_relaxed);
|
||||
total.store(total.load(std::memory_order_relaxed) + us, std::memory_order_relaxed);
|
||||
if (us > worst.load(std::memory_order_relaxed)) worst.store(us, std::memory_order_relaxed);
|
||||
}
|
||||
std::vector<bool> enabled_;
|
||||
|
||||
Partition part_;
|
||||
|
||||
+176
-80
@@ -30,10 +30,23 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
|
||||
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
|
||||
/// mistaken for something the user placed.
|
||||
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
|
||||
/// The window-start caption's colour: quiet chrome, brighter than a grid line.
|
||||
constexpr plot::u32 kTimeNoteColor = 0xC8C8C8u;
|
||||
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
|
||||
|
||||
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
|
||||
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
|
||||
/// figures below a minute (of the SI-prefixed mantissa under a second, of the seconds above it),
|
||||
/// whole seconds through `m:ss`/`h:mm:ss` up to a day, and three figures of days above. So the
|
||||
/// resolution is a step function of the *magnitude* and not of the span -- which is the whole
|
||||
/// reason a relative axis is ever needed, and the reason a span threshold alone gets it wrong in
|
||||
/// both directions.
|
||||
double time_label_resolution(double t) {
|
||||
t = std::fabs(t);
|
||||
if (!std::isfinite(t) || !(t > 0.0)) return 0.0;
|
||||
if (t >= 86400.0) return 86400.0 * std::pow(10.0, std::floor(std::log10(t / 86400.0)) - 2.0);
|
||||
if (t >= 60.0) return 1.0;
|
||||
return std::pow(10.0, std::floor(std::log10(t)) - 2.0);
|
||||
}
|
||||
|
||||
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
|
||||
/// The measurement marks' stroke: thinner than the chrome that frames the plot (a trace is 1.5,
|
||||
/// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over
|
||||
@@ -56,6 +69,40 @@ plot::Color RowSet::palette_color(int index) {
|
||||
|
||||
plot::Color RowSet::unplotted_color() { return plot::Color::hex(kUnplottedColor); }
|
||||
|
||||
plot::Color RowSet::signal_color(const std::string& signal, int palette_index) const {
|
||||
const auto it = signal_colors_.find(signal);
|
||||
if (it != signal_colors_.end()) return plot::Color::hex(it->second);
|
||||
return palette_color(palette_index);
|
||||
}
|
||||
|
||||
void RowSet::mark_signal_dirty(const std::string& signal) {
|
||||
for (int i = 0; i < kMaxRows; ++i) {
|
||||
const Row& r = row(i);
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
if (r.signal_name[static_cast<std::size_t>(s)] != signal) continue;
|
||||
row_dirty_[static_cast<std::size_t>(i)] = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void RowSet::set_signal_color(const std::string& signal, plot::u32 rgb) {
|
||||
if (signal.empty()) return;
|
||||
// Masked to 24 bits: the palette is `0xRRGGBB` and `Color::hex()` reads it as such, so an alpha
|
||||
// byte left in the high octet by a caller converting from a `GdkRGBA` would be silently ignored
|
||||
// by the plot and silently written to the session file.
|
||||
const plot::u32 want = rgb & 0x00FFFFFFu;
|
||||
const auto it = signal_colors_.find(signal);
|
||||
if (it != signal_colors_.end() && it->second == want) return;
|
||||
signal_colors_[signal] = want;
|
||||
mark_signal_dirty(signal);
|
||||
}
|
||||
|
||||
void RowSet::clear_signal_color(const std::string& signal) {
|
||||
if (signal_colors_.erase(signal) == 0) return;
|
||||
mark_signal_dirty(signal);
|
||||
}
|
||||
|
||||
RowSet::RowSet() { rebuild_all(); }
|
||||
|
||||
void RowSet::set_row_count(int rows) {
|
||||
@@ -112,13 +159,13 @@ bool RowSet::remove_row(int at) {
|
||||
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||
r.plot = &p;
|
||||
r.axis_x = r.plot->add_x_axis(
|
||||
{"time", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
||||
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
||||
r.axis_y = r.plot->add_y_axis(
|
||||
{"value", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||
const std::size_t i = static_cast<std::size_t>(s);
|
||||
r.plot->add_trace(
|
||||
{&r.slots[i], palette_color(r.color_index[i]), 1.2f, r.axis_x, r.axis_y, r.name[i]});
|
||||
r.plot->add_trace({&r.slots[i], signal_color(r.signal_name[i], r.color_index[i]), 1.2f,
|
||||
r.axis_x, r.axis_y, r.name[i]});
|
||||
}
|
||||
r.cursor_a = r.plot->add_cursor(0.0, plot::Color::hex(kCursorAColor));
|
||||
r.cursor_b = r.plot->add_cursor(0.0, plot::Color::hex(kCursorBColor));
|
||||
@@ -155,11 +202,6 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
|
||||
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
|
||||
}
|
||||
// The window-start caption for the relative x labels: an annotation, hidden until the
|
||||
// labels are relative. Inside the axes on purpose -- its text changes with the window, and
|
||||
// an axis title of a changing width would relayout the plot every frame.
|
||||
r.time_origin_note = r.plot->add_annotation(
|
||||
plot::Annotation{0.0, 0.0, "", plot::Color::hex(kTimeNoteColor), r.axis_x, r.axis_y, false});
|
||||
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
|
||||
// A marker, not an instrument: it must sit exactly on the edge's time, so its
|
||||
// snap is Free and the controller never rounds it onto a sample.
|
||||
@@ -191,7 +233,7 @@ void RowSet::rebuild_all() {
|
||||
}
|
||||
figure_->link_x(static_cast<plot::u32>(i), x_group_);
|
||||
}
|
||||
if (y_gutter_px_ > 0.0) apply_y_gutter();
|
||||
apply_gutters();
|
||||
// A new figure starts with no window at all, so the one the rows were showing has to
|
||||
// be restated -- as the pin it was, if an event is on screen: assigning a trace or
|
||||
// changing the row count while a capture is displayed must not throw the view back
|
||||
@@ -396,18 +438,30 @@ void RowSet::place_crossings() {
|
||||
if (ida >= r.plot->annotation_count() || idb >= r.plot->annotation_count()) continue;
|
||||
plot::Annotation a = r.plot->annotation(ida);
|
||||
plot::Annotation b = r.plot->annotation(idb);
|
||||
double xa = 0.0;
|
||||
double ya = 0.0;
|
||||
double xb = 0.0;
|
||||
double yb = 0.0;
|
||||
const bool has_a = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_a_x_, ya);
|
||||
const bool has_b = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_b_x_, yb);
|
||||
// `crosshair_point()`, not `crosshair_value()`: the number beside this dot is a *stored
|
||||
// sample's*, so the dot belongs on that sample and not on the marker's own column. The
|
||||
// two differ by up to half a sample period, which is nothing at a whole-window view and
|
||||
// the whole width of the panel once the view is zoomed to a handful of samples -- the
|
||||
// state in which a reader is asking "which point is that?" in the first place. It leaves
|
||||
// the dot a little off its vertical line by design: the line says *where the marker is*
|
||||
// and the dot says *which datum is being quoted*, and when the marker is between two
|
||||
// samples those are not the same place.
|
||||
const bool has_a =
|
||||
mix && r.plot->crosshair_point(static_cast<plot::u32>(s), cursor_a_x_, xa, ya);
|
||||
const bool has_b =
|
||||
mix && r.plot->crosshair_point(static_cast<plot::u32>(s), cursor_b_x_, xb, yb);
|
||||
a.visible = has_a;
|
||||
b.visible = has_b;
|
||||
if (has_a) {
|
||||
a.x = cursor_a_x_;
|
||||
a.x = xa;
|
||||
a.y = ya;
|
||||
}
|
||||
if (has_b) {
|
||||
b.x = cursor_b_x_;
|
||||
b.x = xb;
|
||||
b.y = yb;
|
||||
}
|
||||
r.plot->set_annotation(ida, a);
|
||||
@@ -497,12 +551,48 @@ bool RowSet::update_time_labels() {
|
||||
const double span = view.span();
|
||||
if (!(span > 0.0) || !std::isfinite(span)) return false;
|
||||
|
||||
// Sub-second windows read relative: `TickFormat::Time` writes three significant digits, so
|
||||
// `12.345671s` and `12.345672s` are the same string and a 1 ms step around a multi-second
|
||||
// time cannot be labelled at all. The band is the switch's hysteresis -- entering below a
|
||||
// second, leaving only above 1.05 -- so a span sitting on the boundary cannot flicker
|
||||
// between two forms while a zoom is nudged.
|
||||
const bool want_relative = relative_x_ ? span < 1.05 : span < 1.0;
|
||||
// A relative axis is the answer to one problem only: a tick step the label form cannot write.
|
||||
// `TickFormat::Time` carries three significant digits, so a 1 ms step around a 12 s stamp
|
||||
// writes `12.3s` on every tick -- there the ticks have to be re-anchored and the anchor named.
|
||||
// The test is therefore the *step against the resolution at the axis' own magnitude*, never the
|
||||
// span: the two part company in both directions, and both cases are real here. A 2 s window at
|
||||
// twenty minutes of uptime is labelled `20:00` throughout and needs the anchor although its
|
||||
// span is wide; a 50 ms trigger window spanning [-25ms, +25ms] is labelled `-20ms .. +20ms`
|
||||
// perfectly well and needs nothing, although its span is narrow. That second case is what made
|
||||
// a triggered sub-second view carry a `t0` caption *and* re-anchor its ticks away from the
|
||||
// edge, which is the one position a triggered axis exists to show.
|
||||
// The 1.05 band is the switch's hysteresis, so a step sitting on the boundary cannot flicker
|
||||
// between two forms while a zoom is nudged, and the `1 - 1e-9` is a slack the *entry* needs for
|
||||
// its own reason: a step read back from two tick values and a resolution from `pow(10, n)`
|
||||
// differ in their last bits even when they are the same number, and `step == res` is a readable
|
||||
// axis (`1.73s, 1.74s, 1.75s` -- one in the last digit is exactly enough), so a decision taken
|
||||
// on those bits picked a different form from one frame to the next.
|
||||
const double mag = std::max(std::fabs(view.min), std::fabs(view.max));
|
||||
const double res = time_label_resolution(mag);
|
||||
// The step the labels are actually written at: the majors the *last* layout produced. They are
|
||||
// a function of the range and the axis' pixel width, not of the label form, so reading them
|
||||
// back here is no feedback loop.
|
||||
double step = 0.0;
|
||||
{
|
||||
const plot::Axis& ax = first.plot->x_axis(first.axis_x);
|
||||
double prev = 0.0;
|
||||
bool have = false;
|
||||
for (const plot::Tick& t : ax.ticks) {
|
||||
if (!t.major) continue;
|
||||
if (have) {
|
||||
step = t.value - prev;
|
||||
break;
|
||||
}
|
||||
prev = t.value;
|
||||
have = true;
|
||||
}
|
||||
}
|
||||
// No ticks yet -- the frames before the first layout. The form is a property of the labels, so
|
||||
// with no labels there is nothing to decide, and keeping the current one is right where
|
||||
// guessing a step from the span is not: a guess that entered the relative form would be
|
||||
// *latched* there by the hysteresis above.
|
||||
const bool want_relative =
|
||||
step > 0.0 ? step < res * (relative_x_ ? 1.05 : 1.0 - 1.0e-9) : relative_x_;
|
||||
const bool changed = want_relative != relative_x_;
|
||||
relative_x_ = want_relative;
|
||||
|
||||
@@ -518,27 +608,25 @@ bool RowSet::update_time_labels() {
|
||||
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
|
||||
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
|
||||
// The caption belongs with the labels, which a figure draws once, on the bottom row (the
|
||||
// one whose x axis is the figure's). Every row gets it parked.
|
||||
// one whose x axis is the figure's). Every other row gets an empty one, which draws nothing.
|
||||
const bool bottom = i == rows_ - 1;
|
||||
if (r.time_origin_note >= r.plot->annotation_count()) continue;
|
||||
plot::Annotation note = r.plot->annotation(r.time_origin_note);
|
||||
note.visible = relative_x_ && bottom;
|
||||
if (note.visible) {
|
||||
// Inside the axes, just above the axis line, at their left edge: the reader adds this
|
||||
// to a tick, so it sits beside the ticks.
|
||||
const plot::Range yr = r.plot->y_range(r.axis_y);
|
||||
note.x = view.min;
|
||||
note.y = yr.min + 0.04 * (yr.span() > 0.0 ? yr.span() : 1.0);
|
||||
char text[64];
|
||||
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
|
||||
// layout set from its range. Reading it back (rather than keeping our own copy) is what
|
||||
// makes the reader's addition exact -- label plus caption is that tick's absolute time.
|
||||
const double shown = r.plot->x_axis_origin(r.axis_x);
|
||||
x_origin_ = shown;
|
||||
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
|
||||
note.text = text;
|
||||
if (!relative_x_ || !bottom) {
|
||||
r.plot->set_x_note("");
|
||||
continue;
|
||||
}
|
||||
r.plot->set_annotation(r.time_origin_note, note);
|
||||
char text[64];
|
||||
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
|
||||
// layout set from its range. Reading it back (rather than keeping our own copy) is what
|
||||
// makes the reader's addition exact -- label plus caption is that tick's absolute time.
|
||||
const double shown = r.plot->x_axis_origin(r.axis_x);
|
||||
x_origin_ = shown;
|
||||
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
|
||||
// Chrome, not an annotation: an annotation is anchored to a *data* coordinate, so the caption
|
||||
// crawled across the plot as the view panned and drifted as it zoomed -- and it came with the
|
||||
// annotation's own pin cross, which pointed at nothing. `set_x_note()` places it in the axes'
|
||||
// bottom-left corner in pixels, on the legend's backplate, which is where the labels it
|
||||
// qualifies are.
|
||||
r.plot->set_x_note(text);
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
@@ -646,20 +734,26 @@ void RowSet::unpin_pause_window() {
|
||||
}
|
||||
|
||||
void RowSet::fit_x() {
|
||||
Row& first = row(0);
|
||||
if (!first.plot || !figure_) return;
|
||||
first.plot->auto_fit_x();
|
||||
const plot::Range fitted = first.plot->x_range(first.axis_x);
|
||||
const double span = fitted.span();
|
||||
if (!std::isfinite(span) || !(span > 0.0)) return;
|
||||
// The same two paths `fit_all()` takes: a held event owns its window and states it
|
||||
// through the link group, a live view re-arms the follow and frames the newest span.
|
||||
if (pinned_) {
|
||||
window_s_ = span;
|
||||
figure_->set_x_link_range(x_group_, fitted);
|
||||
} else {
|
||||
set_time_window(span);
|
||||
if (!figure_) return;
|
||||
if (!pinned_) {
|
||||
// Live: the same path the window control and a resume take, with the window it already has.
|
||||
// That re-arms the follow and parks the newest `window_s_` at the live edge, which is the
|
||||
// whole of what a live view may show.
|
||||
set_time_window(window_s_);
|
||||
return;
|
||||
}
|
||||
// Pinned (a held event or a paused view): the pin's full range, and the pin stays. The
|
||||
// *members* have to be written as well as the group, because `hold_pinned_window()` prefers a
|
||||
// member that has moved since our own last statement -- that is how it tells a gesture from its
|
||||
// own echo -- so writing the group alone would be reverted to the zoomed-in member next frame.
|
||||
const plot::Range pin{-pinned_pre_, pinned_post_};
|
||||
if (!(pin.span() > 0.0) || !std::isfinite(pin.min) || !std::isfinite(pin.max)) return;
|
||||
for (int i = 0; i < rows_; ++i) {
|
||||
Row& r = row(i);
|
||||
if (r.plot) r.plot->set_x_range(r.axis_x, pin);
|
||||
}
|
||||
pinned_stated_ = pin;
|
||||
figure_->set_x_link_range(x_group_, pin);
|
||||
}
|
||||
|
||||
void RowSet::sync_cursors(int drag_row) {
|
||||
@@ -778,30 +872,22 @@ void RowSet::fit_all() {
|
||||
Row& r = row(i);
|
||||
if (r.plot) r.plot->auto_fit_y(r.axis_y);
|
||||
}
|
||||
Row& first = row(0);
|
||||
if (!first.plot || !figure_) return;
|
||||
// Row 0 fits its own x to the data it holds; that span becomes the shared window, and
|
||||
// stating it through the group is what puts every row on it.
|
||||
first.plot->auto_fit_x();
|
||||
const plot::Range fitted = first.plot->x_range(first.axis_x);
|
||||
const double span = fitted.span();
|
||||
if (!std::isfinite(span) || !(span > 0.0)) return;
|
||||
if (pinned_) {
|
||||
// Fitting a captured event: the window is the snapshot's own `[-pre, +post]` span,
|
||||
// and `set_time_window()` would re-arm the follow and re-frame on a live edge that
|
||||
// trigger mode has nothing bound to.
|
||||
window_s_ = span;
|
||||
figure_->set_x_link_range(x_group_, fitted);
|
||||
} else {
|
||||
set_time_window(span);
|
||||
}
|
||||
// The x half is `fit_x()`'s exactly -- the zoom undone, the time window untouched -- and
|
||||
// sharing it is what keeps the two fits from drifting apart.
|
||||
fit_x();
|
||||
}
|
||||
|
||||
void RowSet::note_y_gutter(double need_px) {
|
||||
double want = std::min(need_px + 6.0, 200.0);
|
||||
// No slack added here, and that is load-bearing: `need_px` is the gutter the row was
|
||||
// *laid out with*, which is already the maximum of its labels and the floor imposed last
|
||||
// frame -- so a few pixels added to it are added again next frame. The `+ 6.0` this used
|
||||
// to carry made the gutter a ratchet that climbed 6 px a frame until it hit the cap
|
||||
// below, which is the whole reason the left margin was `200` of an 870 px viewport (and
|
||||
// the `axes.w changes: 17` beside it). The label padding the reservation below adds is
|
||||
// the slack, once.
|
||||
double want = std::min(need_px, 200.0);
|
||||
// Reserve room for the widest label the formatter can produce, once, with the
|
||||
// font the labels are actually drawn in (the embedded font is monospaced, so a
|
||||
// character count is an exact width).
|
||||
// font the labels are actually drawn in.
|
||||
if (!y_gutter_reserved_) {
|
||||
for (int i = 0; i < rows_; ++i) {
|
||||
Row& r = row(i);
|
||||
@@ -809,10 +895,19 @@ void RowSet::note_y_gutter(double need_px) {
|
||||
const plot::render::TextBackend* text = r.plot->text_backend();
|
||||
if (text == nullptr) continue;
|
||||
const plot::render::Style& style = r.plot->style();
|
||||
const double advance = double(text->measure("0123456789")) / 10.0;
|
||||
const double pad = double(style.device(style.grid_label_pad));
|
||||
// "-1.23e-45" is the longest label `format_si()`'s fallback can print.
|
||||
want = std::max(want, 9.0 * advance + 2.0 * pad + 2.0);
|
||||
// A sign, three significant figures and a one-glyph SI prefix: the widest label
|
||||
// `format_si()` writes for an axis with no unit, measured rather than counted in
|
||||
// characters. Its `%.3g` fallback is wider (`-1.23e-45`) and was what this reserved,
|
||||
// which cost a *third* of the gutter for a label no signal on a `Si` axis produces --
|
||||
// it needs a value past 1e24, and `note_y_gutter()` is a monotone maximum over the
|
||||
// measured need, so one that did appear widens the gutter for good anyway.
|
||||
want = std::max(want, double(text->measure("-1.23m")) + 2.0 * pad + 2.0);
|
||||
// The other side, from the same metrics. An x tick label is *centred* on its tick and
|
||||
// the newest tick sits on the axes' right edge (a live axis is [-window, 0]), so half
|
||||
// the widest time label is what the rightmost one needs to be drawn at all -- and that
|
||||
// is also the band which keeps the newest sample's ink off the frame.
|
||||
right_gutter_px_ = 0.5 * double(text->measure("-1.23ms")) + pad;
|
||||
y_gutter_reserved_ = true;
|
||||
break;
|
||||
}
|
||||
@@ -820,9 +915,10 @@ void RowSet::note_y_gutter(double need_px) {
|
||||
if (want > y_gutter_px_) y_gutter_px_ = want;
|
||||
}
|
||||
|
||||
void RowSet::apply_y_gutter() {
|
||||
if (!figure_ || !(y_gutter_px_ > 0.0)) return;
|
||||
figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
|
||||
void RowSet::apply_gutters() {
|
||||
if (!figure_) return;
|
||||
if (y_gutter_px_ > 0.0) figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
|
||||
figure_->set_right_gutter(static_cast<f32>(right_gutter_px_));
|
||||
}
|
||||
|
||||
void RowSet::set_trigger_marker(double x, bool visible) {
|
||||
|
||||
+79
-24
@@ -9,6 +9,7 @@
|
||||
#include <plot/plot/plot.hpp>
|
||||
|
||||
#include <array>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
@@ -106,11 +107,6 @@ class RowSet {
|
||||
/// it keeps its position whatever the cursor toggle says.
|
||||
plot::u32 trigger_marker = 0;
|
||||
|
||||
/// The caption naming the window's start while the x labels are relative: an annotation
|
||||
/// inside the axes, so its text can change every frame without moving the layout (an axis
|
||||
/// *title* would, and it would be a different width each time).
|
||||
plot::u32 time_origin_note = 0;
|
||||
|
||||
/// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
|
||||
/// drop a cursor after the fact) and parked unless a measurement is using the slot.
|
||||
std::array<plot::u32, kMeasureMarks> measure_h{};
|
||||
@@ -183,6 +179,14 @@ class RowSet {
|
||||
void set_time_window(double seconds);
|
||||
double time_window() const noexcept { return window_s_; }
|
||||
|
||||
/// The newest x any row's live traces hold, which is what the link group follows -- and, since
|
||||
/// it is reported in the display epoch currently in force, also what the frame tick subtracts
|
||||
/// from that epoch to park the newest sample at x = 0. It must be read **after** `bind()` and
|
||||
/// `acquire()`: it asks the bound series, so before them it answers for the previous frame's
|
||||
/// window, and a frame's production at 1 MS/s over a 50 ms view is a third of the screen.
|
||||
/// False when no row holds a streaming trace.
|
||||
bool live_edge(double& newest) const;
|
||||
|
||||
void set_y_auto(int row, bool on);
|
||||
void set_y_range(int row, double lo, double hi);
|
||||
|
||||
@@ -196,7 +200,13 @@ class RowSet {
|
||||
void place_crossings();
|
||||
CursorMode cursor_mode() const noexcept { return cursor_mode_; }
|
||||
|
||||
/// The two vertical cursors' stream times; shared by every row.
|
||||
/// The two vertical cursors' positions, in the **display** x the axis reads -- not stream
|
||||
/// seconds, which they coincided with only while `display_epoch()` was always 0. A live view
|
||||
/// is newest-relative now, so a cursor left at -1.5 s keeps meaning "1.5 s ago" and the data
|
||||
/// flows under it, which is what a bench scope's roll mode does and is the behaviour that was
|
||||
/// asked for. They are read back out of the plots and written to them unchanged, so there is
|
||||
/// nothing to convert; anything that needs the stream's own clock goes through
|
||||
/// `ScopeModel::stream_at_x0()`. Shared by every row.
|
||||
double cursor_a_x() const noexcept { return cursor_a_x_; }
|
||||
double cursor_b_x() const noexcept { return cursor_b_x_; }
|
||||
/// One row's ruler values, or 0 for a row that does not exist.
|
||||
@@ -298,11 +308,25 @@ class RowSet {
|
||||
void set_data_pin(double lo, double hi);
|
||||
void unpin_pause_window();
|
||||
|
||||
/// Frames the x window on the data row 0 holds, leaving every y axis alone. The x
|
||||
/// counterpart of `fit_all()`, for the one thing the y axes cannot say: how wide the
|
||||
/// window should be.
|
||||
/// Undoes an x zoom: puts the view back on the whole time window, leaving every y axis alone.
|
||||
/// The x counterpart of `fit_all()`.
|
||||
///
|
||||
/// **It does not change the time window** -- `window_s_` is read, never written. A fit is a
|
||||
/// plot interaction, and the window is also the trigger's event span, the buffer partition and
|
||||
/// the control's readout, so a fit that *framed the data* instead would redefine all of that
|
||||
/// from a gesture (and re-plan the rings, dropping the history). What a user asks for here is
|
||||
/// the zoom undone: a 2 s window zoomed into reads 2 s again.
|
||||
///
|
||||
/// Which 2 s depends on the state, and both cases restore the *whole* of what the view is
|
||||
/// allowed to show: live, the newest window at the live edge; pinned (a held event or a paused
|
||||
/// view), the pin's full range, with the pin kept -- `set_time_window()` would clear it and
|
||||
/// re-frame on a live edge that a frozen view has nothing bound to.
|
||||
void fit_x();
|
||||
|
||||
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
|
||||
/// `fit_x()` branches on, which is why the fit probe reports it.
|
||||
bool window_pinned() const noexcept { return pinned_; }
|
||||
|
||||
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
|
||||
/// a cursor), and nothing otherwise.
|
||||
///
|
||||
@@ -336,10 +360,9 @@ class RowSet {
|
||||
/// 0, so nothing outside the pre+post window is visible.
|
||||
void pin_event_window(double pre_s, double post_s);
|
||||
|
||||
/// Fits every row's y axis to the data it holds, and re-frames the shared x window
|
||||
/// on what row 0 retains (the same thing the plot's own `f` key does). The window
|
||||
/// the toolbar reports becomes the fitted span, so the dropdown can say what is
|
||||
/// actually on screen.
|
||||
/// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
|
||||
/// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change
|
||||
/// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
|
||||
void fit_all();
|
||||
|
||||
/// Reports the left gutter the rows currently need, and reserves at least the width
|
||||
@@ -351,18 +374,22 @@ class RowSet {
|
||||
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
|
||||
/// floor and the library still takes the larger of the two, a floor smaller than
|
||||
/// the labels cannot stop that; the reservation therefore covers the widest label
|
||||
/// `format_si()` can produce (three significant digits, a sign and a prefix, or the
|
||||
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font.
|
||||
/// Both reservations only ever grow, so the gutter is constant in practice.
|
||||
/// `format_si()` writes for an axis with no unit -- a sign, three significant figures
|
||||
/// and a one-glyph prefix -- plus the label padding, measured once with the plot's own
|
||||
/// font. Both reservations only ever grow, so the gutter is constant in practice.
|
||||
///
|
||||
/// The same metrics fix the **right** gutter, which is a want and not a measurement:
|
||||
/// see `apply_gutters()`.
|
||||
void note_y_gutter(double need_px);
|
||||
|
||||
/// Applies the stable gutter as the figure's floor. Call once per frame, before the
|
||||
/// frame is drawn.
|
||||
/// Applies the stable left gutter as the figure's floor, and the right gutter beside
|
||||
/// it. Call once per frame, before the frame is drawn.
|
||||
///
|
||||
/// It has to be the *figure's* floor and not each row's: `Figure::layout()` clears
|
||||
/// every row's `set_min_y_gutter()` to measure what its own labels need, so a floor
|
||||
/// written onto the rows survives exactly until the next layout.
|
||||
void apply_y_gutter();
|
||||
/// Both have to be the *figure's* and not each row's: `Figure::layout()` clears every
|
||||
/// row's `set_min_y_gutter()` to measure what its own labels need, so a floor written
|
||||
/// onto the rows survives exactly until the next layout, and the rows have to end on
|
||||
/// one column as well as start on one.
|
||||
void apply_gutters();
|
||||
|
||||
/// The palette entry `index`, wrapped. Keyed by the *signal*, so that the colour a
|
||||
/// trace is drawn in is the colour the signal list's chip shows, on every row the
|
||||
@@ -373,7 +400,30 @@ class RowSet {
|
||||
/// names beside it shifted sideways as signals were placed.
|
||||
static plot::Color unplotted_color();
|
||||
|
||||
/// The colour a signal is drawn in: the one the user chose for it if they chose one, the
|
||||
/// positional palette entry otherwise.
|
||||
///
|
||||
/// **The one resolver**, used by `install()` for the trace and by the panels for their chips.
|
||||
/// A chip exists to say which trace on screen a panel row is about, so a chip that resolved
|
||||
/// a colour by a different route than the trace is the single failure the chip cannot
|
||||
/// survive -- and an override is exactly the state in which two routes would disagree.
|
||||
plot::Color signal_color(const std::string& signal, int palette_index) const;
|
||||
/// Gives `signal` the colour `rgb` (0xRRGGBB), by *name*: a palette entry is positional, so an
|
||||
/// override held by index would move to another signal on the next CONFIG. Marks every row that
|
||||
/// plots it dirty, because a trace's colour is fixed at `add_trace()` -- a new colour is a new
|
||||
/// figure, which is the same path `assign()` already takes.
|
||||
void set_signal_color(const std::string& signal, plot::u32 rgb);
|
||||
/// Back to the palette entry. A no-op, and no rebuild, when there was no override.
|
||||
void clear_signal_color(const std::string& signal);
|
||||
/// Every override, for the session file. Ordered by name, so a saved session's colour
|
||||
/// tables do not reshuffle between writes.
|
||||
const std::map<std::string, plot::u32>& signal_colors() const noexcept { return signal_colors_; }
|
||||
|
||||
private:
|
||||
/// Marks every row with `signal` in a slot for rebuild. Not `assign()`: nothing about what
|
||||
/// the slot *holds* changed, only the colour its trace has to be built with.
|
||||
void mark_signal_dirty(const std::string& signal);
|
||||
|
||||
/// Moves one row's *persistent* state -- what it plots, in which colours, where its
|
||||
/// cursors are -- onto another row. Deliberately not a `Row` assignment: a `Row` also
|
||||
/// carries the figure handles (`plot`, the axis and cursor ids) and the `SlotSeries`
|
||||
@@ -391,8 +441,6 @@ class RowSet {
|
||||
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot
|
||||
/// mid-drag has already been through that act.
|
||||
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped);
|
||||
/// The newest x any row's live traces hold, which is what the group follows.
|
||||
bool live_edge(double& newest) const;
|
||||
|
||||
std::unique_ptr<plot::Figure> figure_;
|
||||
/// The one x link group every visible row belongs to. Groups are numbered from 1.
|
||||
@@ -411,8 +459,15 @@ class RowSet {
|
||||
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back.
|
||||
plot::Range pinned_stated_{0.0, 0.0};
|
||||
double y_gutter_px_ = 0.0;
|
||||
/// The empty band kept to the right of the data, device pixels. Nothing is drawn in it,
|
||||
/// so it is reserved once from the font's metrics and never measured or grown.
|
||||
double right_gutter_px_ = 0.0;
|
||||
bool y_gutter_reserved_ = false;
|
||||
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
|
||||
/// The colours the user chose, by signal name (see `set_signal_color()`). Sparse on purpose:
|
||||
/// an absent entry means "the palette decides", which is what keeps a new CONFIG's signals
|
||||
/// coloured without anything having to seed them.
|
||||
std::map<std::string, plot::u32> signal_colors_;
|
||||
CursorMode cursor_mode_ = CursorMode::Vertical;
|
||||
bool cursors_on_ = false;
|
||||
bool relative_x_ = false;
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,424 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#include "source_dialog.hpp"
|
||||
|
||||
#include "layout_store.hpp"
|
||||
|
||||
#include <adwaita.h>
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace udpscope {
|
||||
namespace {
|
||||
|
||||
/// A chooser row's source becomes the connection to open. The interface comes along with the
|
||||
/// group: it is the local address the join happens on, so storing one without the other would
|
||||
/// reconnect to the right group on the wrong path.
|
||||
void apply_source(Config& cfg, const KnownSource& src) {
|
||||
cfg.server = src.host;
|
||||
cfg.port = src.port;
|
||||
cfg.multicast = src.multicast;
|
||||
cfg.interface_addr = src.interface_addr;
|
||||
}
|
||||
|
||||
/// One line per known source on stderr, in the style of the probes in `ui.cpp`.
|
||||
void report_sources(const std::vector<KnownSource>& known) {
|
||||
std::fprintf(stderr, "[sources] %zu known:", known.size());
|
||||
for (const KnownSource& s : known) {
|
||||
std::fprintf(stderr, " %s:%u plots=%d signals=%d", s.host.c_str(), s.port, s.rows, s.signals);
|
||||
if (!s.multicast.empty()) std::fprintf(stderr, " multicast=%s", s.multicast.c_str());
|
||||
if (!s.interface_addr.empty()) {
|
||||
std::fprintf(stderr, " interface=%s", s.interface_addr.c_str());
|
||||
}
|
||||
std::fprintf(stderr, " |");
|
||||
}
|
||||
std::fprintf(stderr, "\n");
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
/// The chooser's own state for the life of its nested loop. A plain local in `choose_source()`:
|
||||
/// the loop runs inside that call, so nothing here outlives the stack frame and there is nothing
|
||||
/// to free on a handler path.
|
||||
struct Chooser {
|
||||
Config* cfg = nullptr;
|
||||
GMainLoop* loop = nullptr;
|
||||
bool chosen = false;
|
||||
GtkWidget* window = nullptr;
|
||||
GtkWidget* list = nullptr;
|
||||
std::vector<KnownSource> known;
|
||||
/// The connection form, created on demand. Members of the chooser rather than a heap struct:
|
||||
/// the chooser outlives the form by construction, so there is nothing to free on a close path
|
||||
/// -- and a dialog whose context is freed by a handler is how this project earned a use-after
|
||||
/// -free in the math editor.
|
||||
GtkWidget* form = nullptr;
|
||||
GtkWidget* form_server = nullptr;
|
||||
GtkWidget* form_port = nullptr;
|
||||
GtkWidget* form_group = nullptr;
|
||||
GtkWidget* form_iface = nullptr;
|
||||
GtkWidget* form_connect = nullptr;
|
||||
/// `UDPSCOPE_PROBE_PICK_MS`'s timeout, kept so a pick made before it fires can cancel it. The
|
||||
/// probe clears it when it runs: `g_source_remove()` on an id whose source already returned
|
||||
/// `G_SOURCE_REMOVE` is a GLib critical, not a no-op.
|
||||
guint pick_source = 0;
|
||||
};
|
||||
|
||||
void chooser_finish(Chooser& c, bool chosen) {
|
||||
c.chosen = chosen;
|
||||
// Destroy the form first: any path that reaches here while the form is up -- a row click, the
|
||||
// Escape key -- would otherwise leave a window whose four handlers are bound to a dead stack
|
||||
// frame. `gtk_window_destroy()` rather than `gtk_window_close()`: the pointer is already
|
||||
// cleared, so the `close-request` handler is not needed here, and `close()` on an unrealized
|
||||
// window returns having done nothing.
|
||||
if (c.form != nullptr) {
|
||||
GtkWidget* w = c.form;
|
||||
c.form = nullptr; // before the destroy, so the `close-request` handler cannot re-enter
|
||||
gtk_window_destroy(GTK_WINDOW(w));
|
||||
}
|
||||
if (c.window != nullptr) {
|
||||
GtkWidget* w = c.window;
|
||||
c.window = nullptr; // before the destroy: `close-request` must not re-enter this
|
||||
gtk_window_destroy(GTK_WINDOW(w));
|
||||
}
|
||||
if (c.loop != nullptr) g_main_loop_quit(c.loop);
|
||||
}
|
||||
|
||||
void chooser_populate(Chooser& c);
|
||||
|
||||
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "source-index"));
|
||||
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
|
||||
apply_source(*c.cfg, c.known[static_cast<std::size_t>(idx)]);
|
||||
chooser_finish(c, true);
|
||||
}
|
||||
|
||||
void on_forget_clicked(GtkButton* button, gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(button), "source-index"));
|
||||
if (idx < 0 || static_cast<std::size_t>(idx) >= c.known.size()) return;
|
||||
forget_source(c.known[static_cast<std::size_t>(idx)]);
|
||||
// Repopulate rather than remove one row: the rows carry their index into `known`, and dropping
|
||||
// an entry renumbers every later one. Re-reading is also the honest answer to "did it go".
|
||||
chooser_populate(c);
|
||||
}
|
||||
|
||||
/// Fills the list box from `known_sources()`. Called again after a forget.
|
||||
void chooser_populate(Chooser& c) {
|
||||
while (GtkWidget* child = gtk_widget_get_first_child(c.list)) {
|
||||
gtk_list_box_remove(GTK_LIST_BOX(c.list), child);
|
||||
}
|
||||
c.known = known_sources();
|
||||
|
||||
if (c.known.empty()) {
|
||||
GtkWidget* empty = gtk_label_new("No saved sources yet.");
|
||||
gtk_widget_add_css_class(empty, "dim-label");
|
||||
gtk_widget_set_margin_top(empty, 24);
|
||||
gtk_widget_set_margin_bottom(empty, 24);
|
||||
// GTK4 auto-wraps any non-row widget appended to a `GtkListBox` in a row, so this is
|
||||
// activatable -- but the wrapped row carries no `"source-index"` data, `g_object_get_data()`
|
||||
// returns null, the index reads 0, and `on_row_activated`'s bounds check refuses it.
|
||||
gtk_list_box_append(GTK_LIST_BOX(c.list), empty);
|
||||
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_NONE);
|
||||
return;
|
||||
}
|
||||
gtk_list_box_set_selection_mode(GTK_LIST_BOX(c.list), GTK_SELECTION_SINGLE);
|
||||
|
||||
for (std::size_t i = 0; i < c.known.size(); ++i) {
|
||||
const KnownSource& s = c.known[i];
|
||||
GtkWidget* row = gtk_list_box_row_new();
|
||||
g_object_set_data(G_OBJECT(row), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
|
||||
|
||||
GtkWidget* line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
|
||||
gtk_widget_set_margin_start(line, 12);
|
||||
gtk_widget_set_margin_end(line, 6);
|
||||
gtk_widget_set_margin_top(line, 6);
|
||||
gtk_widget_set_margin_bottom(line, 6);
|
||||
|
||||
GtkWidget* text = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
|
||||
gtk_widget_set_hexpand(text, TRUE);
|
||||
|
||||
// The *server*, even for a multicast session: that is the host the file is named after and
|
||||
// the one CONFIG is fetched from. Titling the row by the group would name it after something
|
||||
// that is not its identity.
|
||||
const std::string title = s.host + ":" + std::to_string(s.port);
|
||||
GtkWidget* title_label = gtk_label_new(title.c_str());
|
||||
gtk_widget_set_halign(title_label, GTK_ALIGN_START);
|
||||
gtk_box_append(GTK_BOX(text), title_label);
|
||||
|
||||
std::string sub = std::to_string(s.rows) + (s.rows == 1 ? " plot, " : " plots, ") +
|
||||
std::to_string(s.signals) + (s.signals == 1 ? " signal" : " signals");
|
||||
if (!s.multicast.empty()) sub += ", multicast " + s.multicast;
|
||||
GtkWidget* sub_label = gtk_label_new(sub.c_str());
|
||||
gtk_widget_add_css_class(sub_label, "dim-label");
|
||||
gtk_widget_set_halign(sub_label, GTK_ALIGN_START);
|
||||
gtk_box_append(GTK_BOX(text), sub_label);
|
||||
gtk_box_append(GTK_BOX(line), text);
|
||||
|
||||
GtkWidget* forget = gtk_button_new_from_icon_name("window-close-symbolic");
|
||||
gtk_widget_set_tooltip_text(forget, "Forget this source (deletes its saved session)");
|
||||
gtk_widget_add_css_class(forget, "flat");
|
||||
gtk_widget_set_valign(forget, GTK_ALIGN_CENTER);
|
||||
g_object_set_data(G_OBJECT(forget), "source-index", GINT_TO_POINTER(static_cast<int>(i)));
|
||||
g_signal_connect(forget, "clicked", G_CALLBACK(on_forget_clicked), &c);
|
||||
gtk_box_append(GTK_BOX(line), forget);
|
||||
|
||||
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), line);
|
||||
gtk_list_box_append(GTK_LIST_BOX(c.list), row);
|
||||
}
|
||||
}
|
||||
|
||||
void form_close(Chooser& c) {
|
||||
if (c.form == nullptr) return;
|
||||
GtkWidget* w = c.form;
|
||||
c.form = nullptr; // before the close, so the handler cannot re-enter
|
||||
gtk_window_close(GTK_WINDOW(w));
|
||||
}
|
||||
|
||||
void on_form_server_changed(GtkEditable* entry, gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
if (c.form_connect == nullptr) return;
|
||||
// The only validation here. A *malformed* address is the acquisition layer's to report -- it
|
||||
// already does, and the starvation notice covers a source that takes the socket and says
|
||||
// nothing -- so checking it twice would be two expressions of one rule.
|
||||
gtk_widget_set_sensitive(c.form_connect, gtk_entry_buffer_get_length(
|
||||
gtk_entry_get_buffer(GTK_ENTRY(entry))) > 0);
|
||||
}
|
||||
|
||||
void on_form_connect(GtkButton*, gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
const char* server = gtk_editable_get_text(GTK_EDITABLE(c.form_server));
|
||||
if (server == nullptr || server[0] == '\0') return;
|
||||
c.cfg->server = server;
|
||||
c.cfg->port = static_cast<std::uint16_t>(
|
||||
gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(c.form_port)));
|
||||
c.cfg->multicast = gtk_editable_get_text(GTK_EDITABLE(c.form_group));
|
||||
c.cfg->interface_addr = gtk_editable_get_text(GTK_EDITABLE(c.form_iface));
|
||||
form_close(c);
|
||||
chooser_finish(c, true);
|
||||
}
|
||||
|
||||
/// A labelled row for the form's grid.
|
||||
GtkWidget* form_field(GtkWidget* grid, int row, const char* label, GtkWidget* widget) {
|
||||
GtkWidget* l = gtk_label_new(label);
|
||||
gtk_widget_set_halign(l, GTK_ALIGN_END);
|
||||
gtk_grid_attach(GTK_GRID(grid), l, 0, row, 1, 1);
|
||||
gtk_widget_set_hexpand(widget, TRUE);
|
||||
gtk_grid_attach(GTK_GRID(grid), widget, 1, row, 1, 1);
|
||||
return widget;
|
||||
}
|
||||
|
||||
void open_form(Chooser& c) {
|
||||
if (c.form != nullptr) {
|
||||
gtk_window_present(GTK_WINDOW(c.form));
|
||||
return;
|
||||
}
|
||||
c.form = gtk_window_new();
|
||||
gtk_window_set_title(GTK_WINDOW(c.form), "New host");
|
||||
gtk_window_set_transient_for(GTK_WINDOW(c.form), GTK_WINDOW(c.window));
|
||||
gtk_window_set_modal(GTK_WINDOW(c.form), TRUE);
|
||||
|
||||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
|
||||
gtk_widget_set_margin_start(box, 12);
|
||||
gtk_widget_set_margin_end(box, 12);
|
||||
gtk_widget_set_margin_top(box, 12);
|
||||
gtk_widget_set_margin_bottom(box, 12);
|
||||
gtk_window_set_child(GTK_WINDOW(c.form), box);
|
||||
|
||||
GtkWidget* grid = gtk_grid_new();
|
||||
gtk_grid_set_row_spacing(GTK_GRID(grid), 6);
|
||||
gtk_grid_set_column_spacing(GTK_GRID(grid), 12);
|
||||
gtk_box_append(GTK_BOX(box), grid);
|
||||
|
||||
c.form_server = form_field(grid, 0, "Server", gtk_entry_new());
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_server), "127.0.0.1");
|
||||
// A spin button holds no decimals by default, which is right for a port and wrong for every
|
||||
// other numeric field in this project -- see the math dialog's fields.
|
||||
c.form_port = form_field(grid, 1, "Port", gtk_spin_button_new_with_range(1, 65535, 1));
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), 8000);
|
||||
c.form_group = form_field(grid, 2, "Multicast group", gtk_entry_new());
|
||||
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_group), "empty = unicast");
|
||||
c.form_iface = form_field(grid, 3, "Interface", gtk_entry_new());
|
||||
gtk_entry_set_placeholder_text(GTK_ENTRY(c.form_iface), "empty = default route");
|
||||
|
||||
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
||||
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
||||
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
form_close(*static_cast<Chooser*>(user));
|
||||
}),
|
||||
&c);
|
||||
c.form_connect = gtk_button_new_with_label("Connect");
|
||||
g_signal_connect(c.form_connect, "clicked", G_CALLBACK(on_form_connect), &c);
|
||||
gtk_box_append(GTK_BOX(actions), cancel);
|
||||
gtk_box_append(GTK_BOX(actions), c.form_connect);
|
||||
gtk_box_append(GTK_BOX(box), actions);
|
||||
|
||||
g_signal_connect(c.form_server, "changed", G_CALLBACK(on_form_server_changed), &c);
|
||||
// `close-request` clears the pointer, and only a *close* emits it: `gtk_window_destroy()` does
|
||||
// not, which is how the math dialog once kept presenting a finalized window.
|
||||
g_signal_connect(c.form, "close-request", G_CALLBACK(+[](GtkWindow*, gpointer user) {
|
||||
static_cast<Chooser*>(user)->form = nullptr;
|
||||
return FALSE;
|
||||
}),
|
||||
&c);
|
||||
|
||||
gtk_window_present(GTK_WINDOW(c.form));
|
||||
gtk_widget_grab_focus(c.form_server);
|
||||
}
|
||||
|
||||
/// What the pick probes report: the connection the chooser came back with. One helper rather than
|
||||
/// the same four lines in each branch, since both probes assert against this exact line.
|
||||
void report_pick(const Chooser& c) {
|
||||
std::fprintf(stderr, "[sources] picked %s:%u multicast=%s interface=%s\n", c.cfg->server.c_str(),
|
||||
c.cfg->port, c.cfg->multicast.c_str(), c.cfg->interface_addr.c_str());
|
||||
std::fflush(stderr);
|
||||
}
|
||||
|
||||
gboolean on_chooser_close(GtkWindow*, gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
c.window = nullptr; // GTK destroys it for us on the default handler
|
||||
chooser_finish(c, false);
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
gboolean on_chooser_key(GtkEventControllerKey*, guint keyval, guint, GdkModifierType,
|
||||
gpointer user) {
|
||||
if (keyval != GDK_KEY_Escape) return FALSE;
|
||||
chooser_finish(*static_cast<Chooser*>(user), false);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
|
||||
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
|
||||
/// dialog built, filled and destroyed is as much the point as the values, the same way
|
||||
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
|
||||
gboolean pick_probe(gpointer user) {
|
||||
Chooser& c = *static_cast<Chooser*>(user);
|
||||
// Every path below returns `G_SOURCE_REMOVE`, so the source is gone the moment this returns and
|
||||
// its id is stale. Clear it here rather than on each path: `choose_source()`'s cancel-the-pending
|
||||
// -probe call would otherwise be a GLib critical on every run that let the probe fire.
|
||||
c.pick_source = 0;
|
||||
|
||||
// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row.
|
||||
if (const char* spec = g_getenv("UDPSCOPE_PROBE_NEWHOST")) {
|
||||
open_form(c);
|
||||
const std::string s = spec;
|
||||
const std::size_t colon = s.rfind(':');
|
||||
gtk_editable_set_text(GTK_EDITABLE(c.form_server),
|
||||
colon == std::string::npos ? s.c_str() : s.substr(0, colon).c_str());
|
||||
if (colon != std::string::npos) {
|
||||
gtk_spin_button_set_value(GTK_SPIN_BUTTON(c.form_port), std::atof(s.c_str() + colon + 1));
|
||||
}
|
||||
std::fprintf(stderr, "[sources] form open, connect_sensitive=%d\n",
|
||||
gtk_widget_get_sensitive(c.form_connect) ? 1 : 0);
|
||||
g_signal_emit_by_name(c.form_connect, "clicked");
|
||||
report_pick(c);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
// Check the source list rather than the row count: `gtk_list_box_append` auto-wraps any
|
||||
// non-row widget in a `GtkListBoxRow`, so `gtk_list_box_get_row_at_index` returns non-null
|
||||
// even when the only list child is the empty-state placeholder label.
|
||||
if (c.known.empty()) {
|
||||
std::fprintf(stderr, "[sources] picked nothing: the list is empty\n");
|
||||
std::fflush(stderr);
|
||||
chooser_finish(c, false);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
GtkListBoxRow* row = gtk_list_box_get_row_at_index(GTK_LIST_BOX(c.list), 0);
|
||||
// `row-activated`, not `gtk_widget_activate()`: this project has already learned that
|
||||
// activating a widget takes a path a real click does not (a `GtkButton` holds for its 250 ms
|
||||
// ACTIVATE_TIMEOUT and then emits into a widget a rebuild has destroyed).
|
||||
g_signal_emit_by_name(c.list, "row-activated", row);
|
||||
report_pick(c);
|
||||
return G_SOURCE_REMOVE;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool choose_source(Config& cfg) {
|
||||
const std::vector<KnownSource> known = known_sources();
|
||||
|
||||
// The discovery half, checkable with no display at all: this returns before `gtk_init()`, so
|
||||
// it asserts what the list found without needing an X server or a GL context. False means the
|
||||
// caller exits 0, which is what "list and exit" wants.
|
||||
if (g_getenv("UDPSCOPE_PROBE_SOURCES") != nullptr) {
|
||||
report_sources(known);
|
||||
return false;
|
||||
}
|
||||
|
||||
// `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;
|
||||
c.window = gtk_window_new();
|
||||
gtk_window_set_title(GTK_WINDOW(c.window), "udpscope \xe2\x80\x94 choose a source");
|
||||
gtk_window_set_default_size(GTK_WINDOW(c.window), 420, 320);
|
||||
|
||||
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||
gtk_window_set_child(GTK_WINDOW(c.window), box);
|
||||
|
||||
GtkWidget* scroller = gtk_scrolled_window_new();
|
||||
gtk_widget_set_vexpand(scroller, TRUE);
|
||||
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER,
|
||||
GTK_POLICY_AUTOMATIC);
|
||||
c.list = gtk_list_box_new();
|
||||
gtk_list_box_set_activate_on_single_click(GTK_LIST_BOX(c.list), TRUE);
|
||||
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
|
||||
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), c.list);
|
||||
gtk_box_append(GTK_BOX(box), scroller);
|
||||
|
||||
GtkWidget* actions = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||
gtk_widget_set_margin_start(actions, 12);
|
||||
gtk_widget_set_margin_end(actions, 12);
|
||||
gtk_widget_set_margin_top(actions, 12);
|
||||
gtk_widget_set_margin_bottom(actions, 12);
|
||||
gtk_widget_set_halign(actions, GTK_ALIGN_END);
|
||||
GtkWidget* new_host = gtk_button_new_with_label("New host\xe2\x80\xa6");
|
||||
g_signal_connect(new_host, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
open_form(*static_cast<Chooser*>(user));
|
||||
}),
|
||||
&c);
|
||||
GtkWidget* cancel = gtk_button_new_with_label("Cancel");
|
||||
g_signal_connect(cancel, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||
chooser_finish(*static_cast<Chooser*>(user), false);
|
||||
}),
|
||||
&c);
|
||||
gtk_box_append(GTK_BOX(actions), new_host);
|
||||
gtk_box_append(GTK_BOX(actions), cancel);
|
||||
gtk_box_append(GTK_BOX(box), actions);
|
||||
|
||||
chooser_populate(c);
|
||||
if (c.known.empty()) gtk_widget_grab_focus(new_host);
|
||||
|
||||
g_signal_connect(c.window, "close-request", G_CALLBACK(on_chooser_close), &c);
|
||||
GtkEventController* keys = gtk_event_controller_key_new();
|
||||
g_signal_connect(keys, "key-pressed", G_CALLBACK(on_chooser_key), &c);
|
||||
gtk_widget_add_controller(c.window, keys);
|
||||
|
||||
gtk_window_present(GTK_WINDOW(c.window));
|
||||
|
||||
// Keep the timeout id so it can be cancelled if the chooser finishes before it fires. A row
|
||||
// clicked before UDPSCOPE_PROBE_PICK_MS elapses would otherwise leave an armed source on the
|
||||
// default main context pointing at the now-gone stack frame.
|
||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_PICK_MS")) {
|
||||
c.pick_source = g_timeout_add(static_cast<guint>(std::atoi(env)), pick_probe, &c);
|
||||
}
|
||||
|
||||
c.loop = g_main_loop_new(nullptr, FALSE);
|
||||
g_main_loop_run(c.loop);
|
||||
g_main_loop_unref(c.loop);
|
||||
c.loop = nullptr;
|
||||
if (c.pick_source != 0) g_source_remove(c.pick_source);
|
||||
return c.chosen;
|
||||
}
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "config.hpp"
|
||||
|
||||
namespace udpscope {
|
||||
|
||||
/// Asks which data source to open: a list of the sources with a stored session, and a form for a
|
||||
/// new one. Returns true with `cfg`'s connection fields filled in, or false if the user declined
|
||||
/// -- in which case `cfg` is untouched and the caller should exit.
|
||||
///
|
||||
/// Runs *before* `ScopeModel` exists, so declining costs no buffer and no socket. It drives its
|
||||
/// own `gtk_init()` and nested `GMainLoop`; `run_ui()`'s `GtkApplication` comes afterwards in the
|
||||
/// same process, which works because `gtk_init()` is idempotent.
|
||||
bool choose_source(Config& cfg);
|
||||
|
||||
} // namespace udpscope
|
||||
@@ -0,0 +1,95 @@
|
||||
// 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.paused) {
|
||||
// Directly after the state word's own field rather than at the end, because it qualifies
|
||||
// *that* word: "paused · 10 kS/s · 2 s window · for 12 s" reads as one statement.
|
||||
out += " · for " + format_elapsed(s.paused_for_s);
|
||||
}
|
||||
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.paused) out += " | paused for " + format_elapsed(s.paused_for_s);
|
||||
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,79 @@
|
||||
// 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: "connecting", "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 --
|
||||
/// so a word added here must be no wider than the widest already in it ("connecting" and
|
||||
/// "event held" are both ten characters). "connecting" is the state before a CONFIG has
|
||||
/// arrived, in which there is no signal list and so nothing any other word here can describe.
|
||||
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;
|
||||
|
||||
/// How long the view has been frozen, in **wall** seconds, and whether it is. The producer
|
||||
/// never stops while paused -- it keeps filling the live ring, which is what makes the resume
|
||||
/// seamless -- so the stream's own clock says nothing about how long the user has been looking
|
||||
/// at a still picture. Written through `format_elapsed()` like the event age, for the same
|
||||
/// reason: it steps to a coarser unit instead of growing digits, and the bar is directly above
|
||||
/// the plot.
|
||||
bool paused = false;
|
||||
double paused_for_s = 0.0;
|
||||
};
|
||||
|
||||
/// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode, " · for
|
||||
/// 12 s" while paused, 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
|
||||
+2892
-445
File diff suppressed because it is too large.
Load diff
+115
-31
@@ -25,7 +25,7 @@ MAGIC = 0x53504455
|
||||
PKT_DATA, PKT_CONFIG, PKT_ACK, PKT_CONNECT, PKT_DISCONNECT = 0, 1, 2, 3, 4
|
||||
T_FLOAT64 = 9
|
||||
QUANT_NONE = 0
|
||||
TIME_PACKET, TIME_FULL_ARRAY = 0, 1
|
||||
TIME_PACKET, TIME_FULL_ARRAY, TIME_FIRST_SAMPLE = 0, 1, 2
|
||||
PUBLISH_ACCUMULATE = 1
|
||||
NO_TIME_SIGNAL = 0xFFFFFFFF
|
||||
|
||||
@@ -50,28 +50,51 @@ def signal_desc(name, *, rate, time_mode, time_idx, unit, rows=1, cols=1, dims=0
|
||||
return bytes(d)
|
||||
|
||||
|
||||
def build_config(rate):
|
||||
def build_config(rate, time_mode=TIME_FULL_ARRAY, unit="V"):
|
||||
sigs = [
|
||||
# The stream's clock: referenced by every data signal, so udpscope treats
|
||||
# it as the time base rather than a plottable channel.
|
||||
signal_desc("time", rate=0.0, time_mode=TIME_FULL_ARRAY,
|
||||
time_idx=NO_TIME_SIGNAL, unit="s"),
|
||||
signal_desc("sine", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"),
|
||||
signal_desc("square", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"),
|
||||
signal_desc("ramp", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"),
|
||||
# it as the time base rather than a plottable channel. Its *own* time_mode
|
||||
# is what udpscope switches on, so `--time-mode first` has to land here.
|
||||
signal_desc(
|
||||
"time", rate=0.0, time_mode=time_mode, time_idx=NO_TIME_SIGNAL, unit="s"
|
||||
),
|
||||
signal_desc("sine", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
|
||||
signal_desc("square", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
|
||||
signal_desc("ramp", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
|
||||
]
|
||||
payload = struct.pack("<I", len(sigs)) + b"".join(sigs)
|
||||
payload += bytes([PUBLISH_ACCUMULATE])
|
||||
return header(PKT_CONFIG, 0, len(payload)) + payload
|
||||
|
||||
|
||||
def build_data(counter, t0, dt, batch, freq):
|
||||
def build_data(counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY, noise=0.0):
|
||||
stamps = [t0 + i * dt for i in range(batch)]
|
||||
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
|
||||
square = [1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps]
|
||||
# The interferer is a tone at 100x the fundamental with a randomly scaled amplitude, which
|
||||
# is the shape that is hardest on anything counting crossings: near the sine's own mid level
|
||||
# the fundamental's slope is 2*pi*f while the interferer's is 100x that, so the signal
|
||||
# recrosses the level several times per true crossing. `--noise 0` is the ideal waveform, and
|
||||
# it is the default because the documented probe assertions are *exact* (`peak to peak=2`,
|
||||
# `top=0.636620`) and any interferer moves the extremes.
|
||||
if noise > 0.0:
|
||||
sine = [
|
||||
math.sin(2.0 * math.pi * freq * t)
|
||||
+ random.random() * noise * math.sin(2.0 * math.pi * 100.0 * freq * t)
|
||||
for t in stamps
|
||||
]
|
||||
else:
|
||||
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
|
||||
square = [
|
||||
1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps
|
||||
]
|
||||
ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]
|
||||
# In FIRST_SAMPLE mode only element 0 of the time signal is meaningful: the producer
|
||||
# stamps the first sample of the batch and says nothing about the rest. A scalar
|
||||
# signal in Accumulate mode still occupies `batch` slots on the wire (the client
|
||||
# reads `num_samples` values for any 1-element signal), so the anchor is repeated --
|
||||
# which is exactly the shape that made udpscope derive a step of zero.
|
||||
wire_time = [t0] * batch if time_mode == TIME_FIRST_SAMPLE else stamps
|
||||
payload = struct.pack("<QI", int(t0 * 1e9), batch)
|
||||
for arr in (stamps, sine, square, ramp):
|
||||
for arr in (wire_time, sine, square, ramp):
|
||||
payload += struct.pack("<%dd" % batch, *arr)
|
||||
return header(PKT_DATA, counter, len(payload)) + payload
|
||||
|
||||
@@ -83,29 +106,78 @@ def main():
|
||||
ap.add_argument("--batch", type=int, default=100, help="samples per packet")
|
||||
ap.add_argument("--freq", type=float, default=5.0, help="waveform frequency, Hz")
|
||||
ap.add_argument("--duration", type=float, default=0.0, help="0 = forever")
|
||||
ap.add_argument("--stream-scale", type=float, default=1.0,
|
||||
help="stream seconds per wall second: 0.1 mimics a producer whose own "
|
||||
"clock advances ten times slower than the wire delivers samples")
|
||||
ap.add_argument("--declared-rate", type=float, default=-1.0,
|
||||
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'")
|
||||
ap.add_argument("--clock-jitter", type=float, default=0.0,
|
||||
help="per-packet stamp jitter, as a fraction of one packet's span: a device "
|
||||
"that stamps each batch from its own clock does not land on a perfect "
|
||||
"grid, so the stream's timestamps wobble about one. Non-cumulative "
|
||||
"(the mean rate is unchanged); 0 = the ideal grid")
|
||||
ap.add_argument(
|
||||
"--stream-scale",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="stream seconds per wall second: 0.1 mimics a producer whose own "
|
||||
"clock advances ten times slower than the wire delivers samples",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--declared-rate",
|
||||
type=float,
|
||||
default=-1.0,
|
||||
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--time-mode",
|
||||
choices=("full", "first"),
|
||||
default="full",
|
||||
help="how the stream carries its clock: 'full' stamps every sample "
|
||||
"(UDPS_TIME_FULL_ARRAY), 'first' stamps only the batch's first "
|
||||
"sample (UDPS_TIME_FIRST_SAMPLE) and leaves the viewer to space the "
|
||||
"rest by the declared cadence -- the shape a real MARTe2-style "
|
||||
"producer with a single u64 clock has",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--unit",
|
||||
default="V",
|
||||
help="the unit the data signals declare in CONFIG; '' declares none, which "
|
||||
"is an ordinary stream (and what every math channel looks like) and is "
|
||||
"the only way a probe can see the unitless readout formatting",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--noise",
|
||||
type=float,
|
||||
default=0.0,
|
||||
help="amplitude of an interferer added to `sine`: a tone at 100x the fundamental, "
|
||||
"scaled by a fresh uniform random factor per sample. 0 = the ideal sine, which is "
|
||||
"what the exact probe assertions (peak to peak = 2, top = 2/pi) are read against; "
|
||||
"0.1 is the figure the measurement hysteresis is sized against",
|
||||
)
|
||||
ap.add_argument(
|
||||
"--clock-jitter",
|
||||
type=float,
|
||||
default=0.0,
|
||||
help="per-packet stamp jitter, as a fraction of one packet's span: a device "
|
||||
"that stamps each batch from its own clock does not land on a perfect "
|
||||
"grid, so the stream's timestamps wobble about one. Non-cumulative "
|
||||
"(the mean rate is unchanged); 0 = the ideal grid",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
dt = args.stream_scale / args.rate # stream seconds per sample
|
||||
period = args.batch / args.rate # wall seconds per packet
|
||||
dt = args.stream_scale / args.rate # stream seconds per sample
|
||||
period = args.batch / args.rate # wall seconds per packet
|
||||
declared = args.rate if args.declared_rate < 0.0 else args.declared_rate
|
||||
time_mode = TIME_FIRST_SAMPLE if args.time_mode == "first" else TIME_FULL_ARRAY
|
||||
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(("0.0.0.0", args.port))
|
||||
sock.settimeout(0.001)
|
||||
print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
|
||||
"stream clock x%.3g, declared %.0f S/s)"
|
||||
% (args.port, args.rate, args.batch, args.stream_scale, declared), flush=True)
|
||||
print(
|
||||
"mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
|
||||
"stream clock x%.3g, declared %.0f S/s, time-mode %s)"
|
||||
% (
|
||||
args.port,
|
||||
args.rate,
|
||||
args.batch,
|
||||
args.stream_scale,
|
||||
declared,
|
||||
args.time_mode,
|
||||
),
|
||||
flush=True,
|
||||
)
|
||||
|
||||
peer = None
|
||||
counter = 1
|
||||
@@ -124,8 +196,11 @@ def main():
|
||||
t_stream = 0.0
|
||||
started = time.monotonic()
|
||||
next_send = started
|
||||
sock.sendto(build_config(declared), peer)
|
||||
print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True)
|
||||
sock.sendto(build_config(declared, time_mode, args.unit), peer)
|
||||
print(
|
||||
"mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer,
|
||||
flush=True,
|
||||
)
|
||||
elif magic == MAGIC and ptype == PKT_DISCONNECT:
|
||||
print("mock udpstreamer: DISCONNECT", flush=True)
|
||||
peer = None
|
||||
@@ -148,11 +223,20 @@ def main():
|
||||
pdt = dt
|
||||
if args.clock_jitter > 0.0:
|
||||
pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter))
|
||||
sock.sendto(build_data(counter, t_stream, pdt, args.batch, args.freq), peer)
|
||||
sock.sendto(
|
||||
build_data(
|
||||
counter, t_stream, pdt, args.batch, args.freq, time_mode, args.noise
|
||||
),
|
||||
peer,
|
||||
)
|
||||
counter += 1
|
||||
t_stream += args.batch * pdt
|
||||
next_send += period
|
||||
if args.duration > 0.0 and started is not None and now - started > args.duration:
|
||||
if (
|
||||
args.duration > 0.0
|
||||
and started is not None
|
||||
and now - started > args.duration
|
||||
):
|
||||
print("mock udpstreamer: done (%d packets)" % counter, flush=True)
|
||||
return
|
||||
|
||||
|
||||
Reference in new issue
Block a user