Compare commits
No files matched your search
@@ -1 +1,2 @@
|
|||||||
build/
|
build/
|
||||||
|
__pycache__/
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
# 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.
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# AGENT.md
|
||||||
|
|
||||||
|
Working notes for an AI agent (or a new contributor) in this repository. **Read
|
||||||
|
[CLAUDE.md](CLAUDE.md) first, then the `docs/claude/` file that covers the area you are touching** —
|
||||||
|
this page is the fast path into it, not a replacement. `SPECS.md` is what was asked for; `README.md`
|
||||||
|
is what was built, deviations and limitations included; when the two disagree, README explains why.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git submodule update --init --recursive # deps/plotter is a submodule
|
||||||
|
cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=RelWithDebInfo
|
||||||
|
cmake --build build # the app
|
||||||
|
cmake --build build --target test-ui # round-trips every .ui through blueprint-compiler
|
||||||
|
cmake --build build --target test-help # yelp-check over help/C/org.udpscope.app/
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
A clean build emits **exactly one warning** (`-Woverlength-strings` on the generated GResource
|
||||||
|
`.c`). Two is a regression.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
There is **no unit test suite**. The check is the app's own headless self-check against a mock
|
||||||
|
source; add a probe rather than a harness:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8000 --duration 120 &
|
||||||
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=16000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||||
|
UDPSCOPE_PROBE_<NAME>_MS=3000 ./build/udpscope --server 127.0.0.1 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Two baselines every run is read against: **`gtk warnings: 0`** in the exit dump (a `Gtk-CRITICAL` is
|
||||||
|
a defect, not noise) and the single build warning above. The probes, their env vars and the value
|
||||||
|
each line must read are in [docs/claude/probe-readings.md](docs/claude/probe-readings.md); how a run
|
||||||
|
is compared with the previous one is in [docs/claude/verification.md](docs/claude/verification.md).
|
||||||
|
For UI work, drive the real surface: the app under `xvfb-run` plus `xdotool`/`import` for pointer and
|
||||||
|
pixel checks (see `tmp/`-free examples in the probe sources), not only internal state reads.
|
||||||
|
|
||||||
|
## Hard rules
|
||||||
|
|
||||||
|
- **SPDX MIT header on every source file**, in the format the existing files use.
|
||||||
|
- **Two threads only**: a producer owning the data source and the GTK main thread owning every
|
||||||
|
widget and the GL context. They share `ScopeModel` under **one mutex**; samples cross through
|
||||||
|
lock-free rings, never through the lock.
|
||||||
|
- **A view never acquires.** Scope windows, full-screen and the layout editor are arrangements of
|
||||||
|
views over the one acquisition; they must not plan buffers, epochs or sources of their own.
|
||||||
|
- **Layout must not depend on content** (docs/claude/invariants-plot-and-layout.md).
|
||||||
|
- **Documentation moves with the code.** A behaviour change updates the matching
|
||||||
|
`docs/claude/invariants-*.md`, `README.md`, the Mallard page under `help/C/org.udpscope.app/`, and
|
||||||
|
the probe reading in `docs/claude/probe-readings.md` — the probe baselines are read as tests.
|
||||||
|
- **`deps/plotter` is a submodule and has its own history**: commit there first, then commit the pin
|
||||||
|
in this repository. Never edit its files without committing them both sides.
|
||||||
|
- No new dependencies, no generated-file edits, no silent changes to `SPECS.md` semantics: a
|
||||||
|
deliberate deviation is written into `README.md` under "Deviations from the spec".
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
Subject: `<area>: <what changed>`, lowercase, imperative, no trailing period (`layout editor: …`,
|
||||||
|
`docs: …`, `plot: …`). The body explains *why* (the defect it fixes, the trade-off it takes), not a
|
||||||
|
restatement of the diff. Keep each commit one coherent change; probes, docs and the code they
|
||||||
|
describe travel together.
|
||||||
@@ -11,6 +11,8 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
find_package(Threads REQUIRED)
|
find_package(Threads REQUIRED)
|
||||||
|
# `CMAKE_INSTALL_DATADIR` (the `share` under the prefix) for the help set's install path.
|
||||||
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
# --- libadwaita: the shortcut overview ---------------------------------------
|
# --- libadwaita: the shortcut overview ---------------------------------------
|
||||||
# GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and libadwaita's
|
# GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and libadwaita's
|
||||||
@@ -61,23 +63,146 @@ add_custom_command(
|
|||||||
COMMENT "Compiling udpscope icon resources"
|
COMMENT "Compiling udpscope icon resources"
|
||||||
VERBATIM)
|
VERBATIM)
|
||||||
|
|
||||||
# --- help pages: Pango markup in a GResource ----------------------------------
|
# --- the manual: a Mallard help set, installed for the `help:` URI ------------
|
||||||
# A second bundle beside the icons'. --c-name is given explicitly so the two generated
|
# The manual is no longer a GResource compiled into the binary. It is a Mallard set that the
|
||||||
# sources cannot collide on symbol names.
|
# desktop help viewer (Yelp on GNOME) reads off disk, because that is the only way `F1` can
|
||||||
set(UDPSCOPE_HELP_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/help)
|
# open a real, searchable, translatable document and the only way the freedesktop Help System
|
||||||
file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS ${UDPSCOPE_HELP_DIR}/*.txt)
|
# Specification's `help:` URI resolves it. The user documentation is then *files*, checked and
|
||||||
add_custom_command(
|
# installed rather than compiled in.
|
||||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
#
|
||||||
COMMAND ${GLIB_COMPILE_RESOURCES}
|
# The document identifier -- the `<name>` in `help:<name>` -- is derived from the desktop
|
||||||
--target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
# file's own basename (its desktop-file id), not written out twice: the same string is the
|
||||||
--sourcedir=${UDPSCOPE_HELP_DIR}
|
# `.desktop` file, the GApplication id and the installed directory. The specification installs
|
||||||
--c-name=udpscope_help
|
# a document at `<datadir>/help/<lang>/<document>/` and resolves `help:<document>` to the first
|
||||||
--generate-source
|
# match under $XDG_DATA_HOME/$XDG_DATA_DIRS, so the directory name *is* the identifier.
|
||||||
${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml
|
# NAME_WE is not used: it strips the *longest* extension, so it would cut `org.udpscope.app`
|
||||||
DEPENDS ${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml ${UDPSCOPE_HELP_FILES}
|
# down to `org`. Strip the two suffixes this one file actually carries, in order.
|
||||||
COMMENT "Compiling udpscope help resources"
|
get_filename_component(_udpscope_desktop
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/data/org.udpscope.app.desktop.in NAME)
|
||||||
|
string(REGEX REPLACE "\\.desktop\\.in$" "" UDPSCOPE_HELP_NAME "${_udpscope_desktop}")
|
||||||
|
unset(_udpscope_desktop)
|
||||||
|
set(UDPSCOPE_HELP_SRC ${CMAKE_CURRENT_SOURCE_DIR}/help/C/${UDPSCOPE_HELP_NAME})
|
||||||
|
file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS
|
||||||
|
${UDPSCOPE_HELP_SRC}/*.page ${UDPSCOPE_HELP_SRC}/figures/*.png)
|
||||||
|
|
||||||
|
# The install rule the specification asks for. `CMAKE_INSTALL_DATADIR` is `share` by default,
|
||||||
|
# so the set lands at `<prefix>/share/help/C/<document>/`, which is exactly where a viewer
|
||||||
|
# looks. There is no install step for the binary itself (see the desktop-entry target below),
|
||||||
|
# but the manual is *read from a datadir* by another process, so it has to be installable.
|
||||||
|
#
|
||||||
|
# `COMPONENT Help` is what lets the opt-in targets below install *only* the manual: it names this
|
||||||
|
# rule, and `cmake --install --component Help` runs it and nothing else. The ordinary
|
||||||
|
# `cmake --install` installs every component, so its behaviour is unchanged -- the component is a
|
||||||
|
# filter a caller may apply, not a change to the default.
|
||||||
|
install(DIRECTORY ${UDPSCOPE_HELP_SRC}/
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/help/C/${UDPSCOPE_HELP_NAME}
|
||||||
|
COMPONENT Help
|
||||||
|
FILES_MATCHING PATTERN "*.page" PATTERN "*.png" PATTERN "*.jpg")
|
||||||
|
|
||||||
|
# --- installing the manual alone: prefix-wide and user-local, both opt-in -----
|
||||||
|
# A user who runs the binary out of a build tree wants *just* the manual where `help:` resolves,
|
||||||
|
# and not the `cmake --install` of a whole project. These two targets are that, one per
|
||||||
|
# destination; neither is built by default and neither is touched by `cmake --build build`.
|
||||||
|
#
|
||||||
|
# `install-help` honours `CMAKE_INSTALL_PREFIX`, so a packager or a system-wide user points it at
|
||||||
|
# `/usr` with -DCMAKE_INSTALL_PREFIX at configure time. It goes through the install machinery
|
||||||
|
# (`cmake --install`) rather than a hand-rolled copy, which is what makes DESTDIR and `--prefix`
|
||||||
|
# behave exactly as they do for the ordinary install. It cannot install system-wide on its own:
|
||||||
|
# `/usr` is root-owned, so the target writes to the configured prefix and a system-wide caller
|
||||||
|
# wraps it in `sudo` -- a build target has no more privilege than the shell that runs it.
|
||||||
|
add_custom_target(install-help
|
||||||
|
COMMAND ${CMAKE_COMMAND} --install ${CMAKE_BINARY_DIR} --component Help
|
||||||
|
COMMENT "Installing the manual into ${CMAKE_INSTALL_PREFIX}"
|
||||||
VERBATIM)
|
VERBATIM)
|
||||||
|
|
||||||
|
# `install-help-user` is the no-root case the launch-failure alert names. It resolves the user
|
||||||
|
# data directory the way `help_installed_path()` does -- `$XDG_DATA_HOME` when it is set,
|
||||||
|
# `$HOME/.local/share` otherwise, the XDG Base Directory Specification's own default -- and copies
|
||||||
|
# the set there. The copy is done by a script rather than by `cmake --install --prefix` because
|
||||||
|
# the destination is the *data directory*, not a prefix: `cmake --install`'s datadir is always
|
||||||
|
# `<prefix>/share`, so an arbitrary `$XDG_DATA_HOME` cannot be expressed as a prefix. The script
|
||||||
|
# prints every path it wrote; the copy uses the install rule's file filter, so both destinations
|
||||||
|
# carry the same tree.
|
||||||
|
add_custom_target(install-help-user
|
||||||
|
COMMAND ${CMAKE_COMMAND}
|
||||||
|
-DHELP_SRC=${UDPSCOPE_HELP_SRC}
|
||||||
|
-DHELP_NAME=${UDPSCOPE_HELP_NAME}
|
||||||
|
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/install_help_user.cmake
|
||||||
|
COMMENT "Installing the manual into the user's XDG data directory"
|
||||||
|
VERBATIM)
|
||||||
|
|
||||||
|
# --- UI files: GtkBuilder XML in a GResource ----------------------------------
|
||||||
|
# The third bundle, for the same reason as the other two: `.ui` files are data, so they are compiled
|
||||||
|
# in rather than looked for at runtime -- and `preprocess="xml-stripblanks"` makes a malformed one a
|
||||||
|
# build failure with a file and line number, instead of a window that silently refuses to open.
|
||||||
|
set(UDPSCOPE_UI_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/ui)
|
||||||
|
file(GLOB UDPSCOPE_UI_FILES CONFIGURE_DEPENDS ${UDPSCOPE_UI_DIR}/*.ui)
|
||||||
|
add_custom_command(
|
||||||
|
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||||
|
COMMAND ${GLIB_COMPILE_RESOURCES}
|
||||||
|
--target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||||
|
--sourcedir=${UDPSCOPE_UI_DIR}
|
||||||
|
--c-name=udpscope_ui
|
||||||
|
--generate-source
|
||||||
|
${UDPSCOPE_UI_DIR}/udpscope-ui.gresource.xml
|
||||||
|
DEPENDS ${UDPSCOPE_UI_DIR}/udpscope-ui.gresource.xml ${UDPSCOPE_UI_FILES}
|
||||||
|
COMMENT "Compiling udpscope UI resources"
|
||||||
|
VERBATIM)
|
||||||
|
# The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the
|
||||||
|
# 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on
|
||||||
|
# generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon bundle
|
||||||
|
# is small enough per file never to need it), so the build stays at its one warning. The manual is
|
||||||
|
# no longer compiled in, so the help bundle's own warning is gone with it.
|
||||||
|
set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||||
|
PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings")
|
||||||
|
|
||||||
|
# --- the UI files: an opt-in check, never part of `cmake --build` ---------------
|
||||||
|
# The interface is hand-written GtkBuilder XML, and nothing in the build sees a mistake in it: a
|
||||||
|
# property a class does not have is a `Gtk-ERROR` when the window is built, and `Gtk-ERROR` *aborts
|
||||||
|
# the process* -- so a typo takes the application down rather than failing to open a dialog.
|
||||||
|
# `xml-stripblanks` catches malformed XML and nothing else, and the probes catch the rest only for the
|
||||||
|
# windows they open.
|
||||||
|
#
|
||||||
|
# blueprint-compiler type-checks every property and child type against the GIR, and it reads XML as
|
||||||
|
# well as blueprint (`decompile` turns one into the other), so `tools/check_ui.py` round-trips each
|
||||||
|
# file through it. The XML stays the source of truth and stays hand-written; nothing here is needed to
|
||||||
|
# build the application, which is the whole point of not converting the files.
|
||||||
|
#
|
||||||
|
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected and
|
||||||
|
# blueprint-compiler is not a build dependency.
|
||||||
|
find_program(UDPSCOPE_PYTHON NAMES python3)
|
||||||
|
if(UDPSCOPE_PYTHON)
|
||||||
|
add_custom_target(test-ui
|
||||||
|
COMMAND ${UDPSCOPE_PYTHON} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_ui.py ${UDPSCOPE_UI_DIR}
|
||||||
|
DEPENDS ${UDPSCOPE_UI_FILES} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_ui.py
|
||||||
|
COMMENT "Checking the UI files with blueprint-compiler"
|
||||||
|
VERBATIM)
|
||||||
|
else()
|
||||||
|
message(WARNING "python3 was not found: the test-ui target is not available")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# --- the manual: an opt-in check, never part of `cmake --build` -----------------
|
||||||
|
# The manual is hand-written Mallard, and `cmake --build` never reads it -- a broken xref or a
|
||||||
|
# missing figure is a page in the viewer with no content or no image, not a build failure.
|
||||||
|
# `tools/check_help.sh` runs yelp-check's four offline subcommands over the set (links, media,
|
||||||
|
# orphans, ids) and renders the whole set to HTML with yelp-build, so the manual is checked the
|
||||||
|
# way the `.ui` files are. `yelp-check validate` is *not* used: it fetches the Mallard RelaxNG
|
||||||
|
# schema over the network, which is not available here and would make the check depend on it.
|
||||||
|
#
|
||||||
|
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected and
|
||||||
|
# yelp-tools is not a build dependency.
|
||||||
|
find_program(UDPSCOPE_YELP_CHECK NAMES yelp-check)
|
||||||
|
find_program(UDPSCOPE_YELP_BUILD NAMES yelp-build)
|
||||||
|
if(UDPSCOPE_YELP_CHECK AND UDPSCOPE_YELP_BUILD)
|
||||||
|
add_custom_target(test-help
|
||||||
|
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_help.sh ${UDPSCOPE_HELP_SRC}
|
||||||
|
DEPENDS ${UDPSCOPE_HELP_FILES} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_help.sh
|
||||||
|
COMMENT "Checking the Mallard help set with yelp-check and yelp-build"
|
||||||
|
VERBATIM)
|
||||||
|
else()
|
||||||
|
message(WARNING "yelp-check/yelp-build were not found: the test-help target is not available")
|
||||||
|
endif()
|
||||||
|
|
||||||
# --- build info: version, commit and timestamp compiled into the binary -------
|
# --- 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
|
# 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
|
# build so the commit and the timestamp belong to the binary that is running rather
|
||||||
@@ -99,16 +224,36 @@ set_source_files_properties(${UDPSCOPE_BUILD_INFO} PROPERTIES GENERATED TRUE)
|
|||||||
# --- the application ----------------------------------------------------------
|
# --- the application ----------------------------------------------------------
|
||||||
add_executable(udpscope
|
add_executable(udpscope
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||||
${UDPSCOPE_BUILD_INFO}
|
${UDPSCOPE_BUILD_INFO}
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/config.cpp
|
src/config.cpp
|
||||||
src/channel.cpp
|
src/channel.cpp
|
||||||
src/model.cpp
|
src/model.cpp
|
||||||
|
src/condition.cpp
|
||||||
src/trigger.cpp
|
src/trigger.cpp
|
||||||
|
src/capture.cpp
|
||||||
|
src/waveform.cpp
|
||||||
|
src/event_store.cpp
|
||||||
src/acquisition.cpp
|
src/acquisition.cpp
|
||||||
|
src/source/source_config.cpp
|
||||||
|
src/source/data_source.cpp
|
||||||
|
src/source/registry.cpp
|
||||||
|
src/source/udps_source.cpp
|
||||||
|
src/source/udps_kind.cpp
|
||||||
|
src/source/tcps_kind.cpp
|
||||||
|
src/source/probe_source.cpp
|
||||||
|
src/source/e1608/e1608_link.cpp
|
||||||
|
src/source/e1608/e1608_device.cpp
|
||||||
|
src/source/e1608/e1608_source.cpp
|
||||||
|
src/source/e1608/e1608_kind.cpp
|
||||||
|
src/source/e1608/e1608_panels.cpp
|
||||||
|
src/source/flatudp/flatudp_signals.cpp
|
||||||
|
src/source/flatudp/flatudp_source.cpp
|
||||||
|
src/source/flatudp/flatudp_kind.cpp
|
||||||
src/plot_host.cpp
|
src/plot_host.cpp
|
||||||
src/export.cpp
|
src/export.cpp
|
||||||
|
src/search.cpp
|
||||||
src/layout_store.cpp
|
src/layout_store.cpp
|
||||||
src/source_dialog.cpp
|
src/source_dialog.cpp
|
||||||
src/measure.cpp
|
src/measure.cpp
|
||||||
@@ -116,10 +261,35 @@ add_executable(udpscope
|
|||||||
src/status_text.cpp
|
src/status_text.cpp
|
||||||
src/shortcuts.cpp
|
src/shortcuts.cpp
|
||||||
src/help_window.cpp
|
src/help_window.cpp
|
||||||
|
src/settings.cpp
|
||||||
|
src/ui_prefs.cpp
|
||||||
src/ui.cpp
|
src/ui.cpp
|
||||||
|
src/ui_probe.cpp
|
||||||
|
src/ui_widgets.cpp
|
||||||
|
src/ui_events.cpp
|
||||||
|
src/ui_status.cpp
|
||||||
|
src/ui_measure.cpp
|
||||||
|
src/ui_cursors.cpp
|
||||||
|
src/ui_dock.cpp
|
||||||
|
src/ui_math.cpp
|
||||||
|
src/ui_layout.cpp
|
||||||
|
src/ui_source.cpp
|
||||||
|
src/ui_slideover.cpp
|
||||||
|
src/ui_badges.cpp
|
||||||
|
src/ui_trigger.cpp
|
||||||
|
src/ui_signals.cpp
|
||||||
|
src/ui_signal_page.cpp
|
||||||
|
src/ui_context_help.cpp
|
||||||
|
src/ui_tour.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||||
|
# The manual's identifier (`show_help()` builds `help:<id>` from it) and its directory in the
|
||||||
|
# source tree (what `help_self_check()` scans). Both come from the block near the top, so the
|
||||||
|
# identifier is the desktop file's basename and nothing is written out twice.
|
||||||
|
target_compile_definitions(udpscope PRIVATE
|
||||||
|
UDPSCOPE_HELP_NAME="${UDPSCOPE_HELP_NAME}"
|
||||||
|
UDPSCOPE_HELP_DIR="${UDPSCOPE_HELP_SRC}")
|
||||||
target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads
|
target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads
|
||||||
PkgConfig::ADWAITA)
|
PkgConfig::ADWAITA)
|
||||||
add_dependencies(udpscope udpscope_build_info)
|
add_dependencies(udpscope udpscope_build_info)
|
||||||
|
|||||||
@@ -36,3 +36,27 @@
|
|||||||
- the time should be computed from the time associated with the signal (not from the client but from the streamed data)
|
- the time should be computed from the time associated with the signal (not from the client but from the streamed data)
|
||||||
- in trigger mode data visualisation should not be refreshed till next event, that's why 2 identical buffer, one is the currently
|
- in trigger mode data visualisation should not be refreshed till next event, that's why 2 identical buffer, one is the currently
|
||||||
viewed event, the other used to acquire live data for next event
|
viewed event, the other used to acquire live data for next event
|
||||||
|
|
||||||
|
## Deviations from the spec
|
||||||
|
|
||||||
|
The following were built differently from what was asked for, each for a reason `README.md` states
|
||||||
|
in full:
|
||||||
|
|
||||||
|
- **The buffer is budgeted in samples per signal, not megabytes** (line 1 asked for "default 200MB
|
||||||
|
each, configurable as cli argument"). `--buffer-samples N` (default 10M, k/M/G suffixes) is the
|
||||||
|
number of samples kept for every acquired signal and for the shared time ring, rounded **down** to a
|
||||||
|
power of two ("up to"; 10M gives 8 388 608), and a separate application setting, `--memory-limit-mb`
|
||||||
|
(default 8192, also in Preferences), is the hard ceiling on the ring bytes, halving the sample budget
|
||||||
|
when the rings would not fit. The retained history therefore no longer depends on the channel count
|
||||||
|
or on relative time. There is no `--buffer-mb` and no compatibility shim; a stored `buffer_mb` is
|
||||||
|
ignored. The "2 identical buffers" were already a single ring plus latched snapshots (see below).
|
||||||
|
- **`todo.md` item 2 — relative timing.** Implemented as `--relative-time`. *As first built* (under
|
||||||
|
the old byte budget; superseded by the item above, where both modes keep the same span and only
|
||||||
|
the memory differs) the retained history in this mode was **41.943 s** against the default
|
||||||
|
**83.886 s** (measured at 100 kS/s, 4 channels, `--buffer-mb 200`): exactly half, because a second f64 ring for absolute time raises a tick's
|
||||||
|
cost from 24 to 32 bytes — a third more — and `Partition::time_capacity` is floored to a power
|
||||||
|
of two, so a third more weight costs a full halving. The design document and the implementation
|
||||||
|
plan both put the cost at ~22%, which is wrong; the per-byte figure of 75% is wrong for the same
|
||||||
|
reason.
|
||||||
|
Absolute stream time is preserved in a separate ring and used for the export's `abs_seconds`
|
||||||
|
column, exactly as `todo.md` requires.
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
#
|
||||||
|
# The `install-help-user` target's body. Copies the Mallard help set into the user's XDG data
|
||||||
|
# directory -- where `help:<id>` resolves without root -- and prints every path it wrote.
|
||||||
|
#
|
||||||
|
# `$XDG_DATA_HOME` is honoured when it is set, and `$HOME/.local/share` is the fallback when it
|
||||||
|
# is not: that is the XDG Base Directory Specification's own stated default for the variable, and
|
||||||
|
# it is the default `help_installed_path()` falls back to as well, so the manual lands where the
|
||||||
|
# application and the desktop viewer look for it. The variable names a *data directory*, not a
|
||||||
|
# prefix -- its `share/` is not appended -- so the set goes to `<data_home>/help/C/<id>/`. An
|
||||||
|
# arbitrary `$XDG_DATA_HOME` need not end in `share`, which is exactly why this copies the tree
|
||||||
|
# rather than deferring to `cmake --install --prefix`, whose datadir is always `<prefix>/share`.
|
||||||
|
#
|
||||||
|
# Like the desktop-entry target, this runs only when it is named: `cmake --build build` never
|
||||||
|
# touches the user's data directory.
|
||||||
|
#
|
||||||
|
# Expects -DHELP_SRC= and -DHELP_NAME= on the command line.
|
||||||
|
|
||||||
|
if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "")
|
||||||
|
set(data_home "$ENV{XDG_DATA_HOME}")
|
||||||
|
elseif(DEFINED ENV{HOME} AND NOT "$ENV{HOME}" STREQUAL "")
|
||||||
|
set(data_home "$ENV{HOME}/.local/share")
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR
|
||||||
|
"XDG_DATA_HOME and HOME are both unset or empty; cannot determine the user data directory")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT DEFINED HELP_SRC OR NOT DEFINED HELP_NAME)
|
||||||
|
message(FATAL_ERROR "HELP_SRC and HELP_NAME are both required")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(dest "${data_home}/help/C/${HELP_NAME}")
|
||||||
|
|
||||||
|
# The same filter as the install() rule, so both destinations carry the same tree: the pages and
|
||||||
|
# the figures, and nothing an editor left behind.
|
||||||
|
file(COPY "${HELP_SRC}/" DESTINATION "${dest}"
|
||||||
|
FILES_MATCHING PATTERN "*.page" PATTERN "*.png" PATTERN "*.jpg")
|
||||||
|
|
||||||
|
# Printed because there is no uninstall target: undoing this is these paths.
|
||||||
|
file(GLOB_RECURSE installed LIST_DIRECTORIES FALSE RELATIVE "${dest}" "${dest}/*")
|
||||||
|
list(SORT installed)
|
||||||
|
foreach(path IN LISTS installed)
|
||||||
|
message(STATUS "wrote ${dest}/${path}")
|
||||||
|
endforeach()
|
||||||
@@ -33,6 +33,13 @@
|
|||||||
/* Tunable limits */
|
/* Tunable limits */
|
||||||
/*---------------------------------------------------------------------------*/
|
/*---------------------------------------------------------------------------*/
|
||||||
|
|
||||||
|
/** Datagrams held waiting for an earlier counter. */
|
||||||
|
#define UDPS_REORDER_SLOTS 64
|
||||||
|
/** Total bytes the reorder ring may hold, so a fast stream cannot grow it without bound. */
|
||||||
|
#define UDPS_REORDER_BYTES (4u * 1024u * 1024u)
|
||||||
|
/** How long a gap is given to resolve before it is committed, by default. */
|
||||||
|
#define UDPS_REORDER_WINDOW_S_DEFAULT 0.020
|
||||||
|
|
||||||
/** Concurrent in-flight reassemblies. The server interleaves at most a couple. */
|
/** Concurrent in-flight reassemblies. The server interleaves at most a couple. */
|
||||||
#define UDPS_MAX_SLOTS 4
|
#define UDPS_MAX_SLOTS 4
|
||||||
|
|
||||||
@@ -41,7 +48,7 @@
|
|||||||
#define UDPS_FRAG_MASK_BYTES (UDPS_MAX_FRAGMENTS / 8)
|
#define UDPS_FRAG_MASK_BYTES (UDPS_MAX_FRAGMENTS / 8)
|
||||||
|
|
||||||
/** Largest datagram we can receive, header included. */
|
/** Largest datagram we can receive, header included. */
|
||||||
#define UDPS_RX_BUF_BYTES (65535 + 17)
|
#define UDPS_RX_BUF_BYTES UDPS_MAX_DATAGRAM_BYTES
|
||||||
|
|
||||||
/** An incomplete reassembly older than this is abandoned. */
|
/** An incomplete reassembly older than this is abandoned. */
|
||||||
#define UDPS_SLOT_STALE_S 2.0
|
#define UDPS_SLOT_STALE_S 2.0
|
||||||
@@ -151,12 +158,23 @@ typedef struct {
|
|||||||
double first_seen;
|
double first_seen;
|
||||||
} udps_slot_t;
|
} udps_slot_t;
|
||||||
|
|
||||||
|
/** One slot in the reorder ring: a raw payload held until its counter is next. */
|
||||||
|
typedef struct {
|
||||||
|
uint8_t *buf; /**< owned; grown on demand to the payload's length */
|
||||||
|
size_t cap;
|
||||||
|
size_t len;
|
||||||
|
uint32_t counter;
|
||||||
|
double recv_time;
|
||||||
|
int used;
|
||||||
|
} udps_held_t;
|
||||||
|
|
||||||
struct udps_client {
|
struct udps_client {
|
||||||
udps_client_config_t cfg;
|
udps_client_config_t cfg;
|
||||||
/* Owned copies: the caller's strings need not outlive the create call. */
|
/* Owned copies: the caller's strings need not outlive the create call. */
|
||||||
char server_addr[128];
|
char server_addr[128];
|
||||||
char mcast_group[128];
|
char mcast_group[128];
|
||||||
char iface_addr[128];
|
char iface_addr[128];
|
||||||
|
char stream_name[256];
|
||||||
|
|
||||||
struct sockaddr_in server_sa;
|
struct sockaddr_in server_sa;
|
||||||
int udp_fd; /**< Unicast receive socket, or joined multicast socket. */
|
int udp_fd; /**< Unicast receive socket, or joined multicast socket. */
|
||||||
@@ -180,13 +198,22 @@ struct udps_client {
|
|||||||
size_t vals_cap;
|
size_t vals_cap;
|
||||||
|
|
||||||
uint8_t *rxbuf;
|
uint8_t *rxbuf;
|
||||||
|
size_t rxbuf_cap; /**< bytes allocated for rxbuf */
|
||||||
|
|
||||||
|
udps_held_t held[UDPS_REORDER_SLOTS];
|
||||||
|
size_t held_bytes; /**< payload bytes currently held */
|
||||||
|
int held_count;
|
||||||
|
uint32_t next_counter; /**< the counter the sequence is waiting for */
|
||||||
|
double gap_deadline; /**< wall time the current gap gives up at; 0 = no gap */
|
||||||
|
|
||||||
uint32_t last_counter;
|
|
||||||
int have_counter;
|
int have_counter;
|
||||||
|
|
||||||
udps_stats_t stats;
|
udps_stats_t stats;
|
||||||
char err[256];
|
char err[256];
|
||||||
|
|
||||||
|
udps_datagram_cb datagram_cb; /**< NULL until set; fires inside poll(). */
|
||||||
|
void *datagram_user;
|
||||||
|
|
||||||
udps_config_cb on_config;
|
udps_config_cb on_config;
|
||||||
udps_data_cb on_data;
|
udps_data_cb on_data;
|
||||||
udps_event_cb on_event;
|
udps_event_cb on_event;
|
||||||
@@ -475,43 +502,23 @@ static int decode_config(udps_client_t *c, const uint8_t *pl, size_t len) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int decode_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
/* Decodes and delivers one DATA payload whose ordering has already been resolved
|
||||||
uint32_t counter, double recv_time) {
|
* by the caller. `lost` counts how many counters the sequence skipped over
|
||||||
|
* before this packet, and `late` marks it as having lost the race past the
|
||||||
|
* reorder window (so the consumer already has a hole where it belongs). */
|
||||||
|
static int deliver_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||||
|
uint32_t counter, double recv_time, uint32_t lost, uint8_t late) {
|
||||||
uint32_t nsamples = 1u;
|
uint32_t nsamples = 1u;
|
||||||
size_t off = 8u;
|
size_t off = 8u;
|
||||||
size_t total = 0u;
|
size_t total = 0u;
|
||||||
size_t written = 0u;
|
size_t written = 0u;
|
||||||
uint32_t i;
|
uint32_t i;
|
||||||
uint32_t lost = 0u;
|
|
||||||
udps_frame_t frame;
|
udps_frame_t frame;
|
||||||
|
|
||||||
if (c->num_sigs == 0u) {
|
if (c->num_sigs == 0u) {
|
||||||
return 0; /* DATA before CONFIG: nothing to decode against. */
|
return 0; /* DATA before CONFIG: nothing to decode against. */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Order the sequence before spending anything on the payload.
|
|
||||||
*
|
|
||||||
* Reassembly completes in arrival order, not counter order, so a packet
|
|
||||||
* delayed or duplicated on the wire surfaces after a newer one has already
|
|
||||||
* been delivered. Its samples carry an older time base: they land on top
|
|
||||||
* of data the consumer already has and leave the span they should have
|
|
||||||
* filled empty. Nothing in the payload distinguishes such a packet from a
|
|
||||||
* good one, only the counter does.
|
|
||||||
*
|
|
||||||
* The counter is a wrapping uint32, so it is ordered by the signed
|
|
||||||
* difference; comparing the values directly would call the first packet
|
|
||||||
* after the wrap stale and reject the stream from then on. */
|
|
||||||
if (c->have_counter) {
|
|
||||||
int32_t delta = (int32_t)(counter - c->last_counter);
|
|
||||||
if (delta <= 0) {
|
|
||||||
c->stats.stale_packets++;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
lost = (uint32_t)delta - 1u;
|
|
||||||
c->stats.counter_gaps += lost;
|
|
||||||
}
|
|
||||||
c->last_counter = counter;
|
|
||||||
c->have_counter = 1;
|
|
||||||
if (len < 8u) {
|
if (len < 8u) {
|
||||||
return fail(c, "DATA payload too short (%lu bytes)", (unsigned long)len);
|
return fail(c, "DATA payload too short (%lu bytes)", (unsigned long)len);
|
||||||
}
|
}
|
||||||
@@ -554,10 +561,12 @@ static int decode_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
|||||||
}
|
}
|
||||||
|
|
||||||
c->stats.frames_delivered++;
|
c->stats.frames_delivered++;
|
||||||
|
c->stats.counter_gaps += lost;
|
||||||
|
|
||||||
if (c->on_data != NULL) {
|
if (c->on_data != NULL) {
|
||||||
frame.counter = counter;
|
frame.counter = counter;
|
||||||
frame.lost = lost;
|
frame.lost = late ? 0u : lost;
|
||||||
|
frame.late = late;
|
||||||
frame.hrt = rd_u64(pl);
|
frame.hrt = rd_u64(pl);
|
||||||
frame.recv_time = recv_time;
|
frame.recv_time = recv_time;
|
||||||
frame.publish_mode = c->publish_mode;
|
frame.publish_mode = c->publish_mode;
|
||||||
@@ -570,6 +579,245 @@ static int decode_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Finds a free slot in the reorder ring and stores the payload there.
|
||||||
|
*
|
||||||
|
* A duplicate counter (same datagram arriving twice on the wire) replaces the
|
||||||
|
* existing slot so one counter never consumes two slots and both get delivered.
|
||||||
|
* The search for that slot therefore runs *before* the capacity checks, and the
|
||||||
|
* checks are applied only on the path that needs a new slot: a duplicate at a
|
||||||
|
* full ring consumes nothing and must still be accepted.
|
||||||
|
* Returns non-zero and holds nothing when there is no room (ring full or byte
|
||||||
|
* budget exhausted); `order_data` below says what the caller then does. */
|
||||||
|
static int hold_packet(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||||
|
uint32_t counter, double recv_time) {
|
||||||
|
int i;
|
||||||
|
int slot = -1;
|
||||||
|
|
||||||
|
/* Duplicate counter: reuse the existing slot to avoid double delivery. */
|
||||||
|
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||||
|
if (c->held[i].used && c->held[i].counter == counter) {
|
||||||
|
slot = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slot >= 0) {
|
||||||
|
/* The replacement releases the length already held, so the budget is
|
||||||
|
* weighed against what the slot will cost rather than what it adds. */
|
||||||
|
if (c->held_bytes - c->held[slot].len + len > UDPS_REORDER_BYTES) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (c->held_count >= UDPS_REORDER_SLOTS) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (c->held_bytes + len > UDPS_REORDER_BYTES) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||||
|
if (!c->held[i].used) {
|
||||||
|
slot = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (slot < 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ensure_cap((void **)&c->held[slot].buf, &c->held[slot].cap, len) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
/* If this is a duplicate replacement, subtract the old length first. */
|
||||||
|
if (c->held[slot].used) {
|
||||||
|
c->held_bytes -= c->held[slot].len;
|
||||||
|
c->held_count--;
|
||||||
|
}
|
||||||
|
memcpy(c->held[slot].buf, pl, len);
|
||||||
|
c->held[slot].len = len;
|
||||||
|
c->held[slot].counter = counter;
|
||||||
|
c->held[slot].recv_time = recv_time;
|
||||||
|
c->held[slot].used = 1;
|
||||||
|
c->held_bytes += len;
|
||||||
|
c->held_count++;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Walks the held ring and delivers every entry whose counter matches the
|
||||||
|
* expected sequence, in order. The first delivery carries `first_lost` (so
|
||||||
|
* a caller that committed a gap can report the correct count); every
|
||||||
|
* subsequent delivery in the same drain pass carries lost = 0.
|
||||||
|
*
|
||||||
|
* Clears the gap deadline once the ring empties. */
|
||||||
|
static int drain_held(udps_client_t *c, uint32_t first_lost) {
|
||||||
|
int found;
|
||||||
|
int i;
|
||||||
|
int rc;
|
||||||
|
uint32_t lost;
|
||||||
|
|
||||||
|
lost = first_lost;
|
||||||
|
do {
|
||||||
|
found = 0;
|
||||||
|
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||||
|
if (c->held[i].used &&
|
||||||
|
(int32_t)(c->held[i].counter - c->next_counter) == 0) {
|
||||||
|
uint32_t cnt = c->held[i].counter;
|
||||||
|
double rt = c->held[i].recv_time;
|
||||||
|
const uint8_t *pl = c->held[i].buf;
|
||||||
|
size_t len = c->held[i].len;
|
||||||
|
/* Release the slot before delivering so deliver_data can call
|
||||||
|
* back into the client without hitting a stale used entry. */
|
||||||
|
c->held_bytes -= len;
|
||||||
|
c->held_count--;
|
||||||
|
c->held[i].used = 0;
|
||||||
|
c->next_counter = cnt + 1u;
|
||||||
|
c->stats.late_resequenced++;
|
||||||
|
rc = deliver_data(c, pl, len, cnt, rt, lost, 0);
|
||||||
|
lost = 0u;
|
||||||
|
if (rc != 0) {
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
found = 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while (found);
|
||||||
|
|
||||||
|
if (c->held_count == 0) {
|
||||||
|
c->gap_deadline = 0.0;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gives up on the current gap: finds the lowest held counter (by signed delta,
|
||||||
|
* not raw value, so a wrap mid-gap is handled correctly), advances
|
||||||
|
* next_counter to it, then drains. The first delivery out of that drain
|
||||||
|
* carries the gap's lost count; the rest carry 0.
|
||||||
|
*
|
||||||
|
* With nothing held it just clears the deadline and returns. */
|
||||||
|
static int commit_gap(udps_client_t *c) {
|
||||||
|
int i;
|
||||||
|
int best = -1;
|
||||||
|
uint32_t lost;
|
||||||
|
|
||||||
|
if (c->held_count == 0) {
|
||||||
|
c->gap_deadline = 0.0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||||
|
if (!c->held[i].used) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (best < 0 ||
|
||||||
|
(int32_t)(c->held[i].counter - c->held[best].counter) < 0) {
|
||||||
|
best = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lost = (uint32_t)(int32_t)(c->held[best].counter - c->next_counter);
|
||||||
|
c->next_counter = c->held[best].counter;
|
||||||
|
return drain_held(c, lost);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Orders the sequence before spending anything on the payload.
|
||||||
|
*
|
||||||
|
* The counter is a wrapping uint32, so it is ordered by the signed difference;
|
||||||
|
* comparing the values directly would call the first packet after the wrap stale
|
||||||
|
* and reject the stream from then on.
|
||||||
|
*
|
||||||
|
* Three cases. A packet that *continues* the sequence is decoded now and costs no
|
||||||
|
* latency at all -- that is the common case and it must stay free. A packet that
|
||||||
|
* runs ahead of the sequence means an earlier one is missing, so it is held and the
|
||||||
|
* gap is given `reorder_window_s` to resolve; a reorder on the wire resolves in
|
||||||
|
* milliseconds, and holding is what turns it back into order. A packet behind the
|
||||||
|
* sequence lost that race: the consumer already has a hole where it belongs, so it
|
||||||
|
* is delivered flagged rather than discarded, which is the whole point -- a dropped
|
||||||
|
* packet cannot fill the hole it left.
|
||||||
|
*
|
||||||
|
* The full ring is specified here, in one place. A packet that needs a slot and
|
||||||
|
* finds none gives up on the current gap -- `commit_gap()` delivers the lowest held
|
||||||
|
* run and so frees at least one slot -- counts the event in
|
||||||
|
* `stats.reorder_overflows`, and the decision is then taken again, once, against
|
||||||
|
* the sequence that commit left behind. That re-decision is why this is a bounded
|
||||||
|
* loop and not a straight line: the commit advances `next_counter` to the lowest
|
||||||
|
* held counter, which may put it past the arriving packet, and the packet is then
|
||||||
|
* behind the sequence rather than ahead of it. Whatever happens, the arriving
|
||||||
|
* packet is held or delivered; it is never dropped, because nothing else records it
|
||||||
|
* and the consumer would reserve a hole for it that nothing could ever backfill.
|
||||||
|
* Past the cap it is delivered *in order* and not flagged late: a packet ahead of
|
||||||
|
* the sequence has no reserved hole, the consumer looks a late packet's hole up by
|
||||||
|
* counter and ignores one it cannot place, so advancing `next_counter` to it and
|
||||||
|
* reporting the counters it skipped as `lost` is what reserves their hole. */
|
||||||
|
static int order_data(udps_client_t *c, const uint8_t *pl, size_t len,
|
||||||
|
uint32_t counter, double recv_time) {
|
||||||
|
int32_t delta;
|
||||||
|
int attempt;
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
if (c->cfg.reorder_window_s <= 0.0) {
|
||||||
|
/* Resequencing off: byte-for-byte the behaviour before this task. `delta`
|
||||||
|
* is measured against the *next* counter rather than the last, which is the
|
||||||
|
* same test shifted by one -- `delta < 0` here is `delta <= 0` there. */
|
||||||
|
uint32_t lost = 0u;
|
||||||
|
if (c->have_counter) {
|
||||||
|
delta = (int32_t)(counter - c->next_counter);
|
||||||
|
if (delta < 0) {
|
||||||
|
c->stats.stale_packets++;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
lost = (uint32_t)delta;
|
||||||
|
}
|
||||||
|
c->have_counter = 1;
|
||||||
|
c->next_counter = counter + 1u;
|
||||||
|
return deliver_data(c, pl, len, counter, recv_time, lost, 0);
|
||||||
|
}
|
||||||
|
if (!c->have_counter) {
|
||||||
|
c->have_counter = 1;
|
||||||
|
c->next_counter = counter + 1u;
|
||||||
|
return deliver_data(c, pl, len, counter, recv_time, 0u, 0);
|
||||||
|
}
|
||||||
|
for (attempt = 0; attempt < 2; attempt++) {
|
||||||
|
delta = (int32_t)(counter - c->next_counter);
|
||||||
|
if (delta == 0) {
|
||||||
|
rc = deliver_data(c, pl, len, counter, recv_time, 0u, 0);
|
||||||
|
c->next_counter = counter + 1u;
|
||||||
|
if (rc == 0) { rc = drain_held(c, 0u); }
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
if (delta < 0) {
|
||||||
|
/* Behind the sequence. */
|
||||||
|
c->stats.late_delivered++;
|
||||||
|
return deliver_data(c, pl, len, counter, recv_time, 0u, 1);
|
||||||
|
}
|
||||||
|
if (hold_packet(c, pl, len, counter, recv_time) == 0) {
|
||||||
|
if (c->gap_deadline == 0.0) {
|
||||||
|
c->gap_deadline = recv_time + c->cfg.reorder_window_s;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
/* No room: give up on the gap rather than growing the ring, and say so
|
||||||
|
* in a counter, so an undersized window is a reading and not a mystery. */
|
||||||
|
c->stats.reorder_overflows++;
|
||||||
|
rc = commit_gap(c);
|
||||||
|
if (rc != 0) {
|
||||||
|
/* The consumer refused a delivery; nothing is freed and nothing more
|
||||||
|
* may be delivered, so the arriving packet is its caller's problem. */
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* The attempt cap is spent and the ring still has no room (the byte budget
|
||||||
|
* cannot fit this one even with a slot free). Deliver it rather than drop it. */
|
||||||
|
delta = (int32_t)(counter - c->next_counter);
|
||||||
|
if (delta < 0) {
|
||||||
|
c->stats.late_delivered++;
|
||||||
|
return deliver_data(c, pl, len, counter, recv_time, 0u, 1);
|
||||||
|
}
|
||||||
|
rc = deliver_data(c, pl, len, counter, recv_time, (uint32_t)delta, 0);
|
||||||
|
c->next_counter = counter + 1u;
|
||||||
|
if (rc == 0) { rc = drain_held(c, 0u); }
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
/*---------------------------------------------------------------------------*/
|
/*---------------------------------------------------------------------------*/
|
||||||
/* Fragment reassembly */
|
/* Fragment reassembly */
|
||||||
/*---------------------------------------------------------------------------*/
|
/*---------------------------------------------------------------------------*/
|
||||||
@@ -579,6 +827,14 @@ static void slot_reset_all(udps_client_t *c) {
|
|||||||
for (i = 0; i < UDPS_MAX_SLOTS; i++) {
|
for (i = 0; i < UDPS_MAX_SLOTS; i++) {
|
||||||
c->slots[i].active = 0;
|
c->slots[i].active = 0;
|
||||||
}
|
}
|
||||||
|
/* A reconnect starts a new sequence; payloads held from the previous one
|
||||||
|
* must not be delivered into it. */
|
||||||
|
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||||
|
c->held[i].used = 0;
|
||||||
|
}
|
||||||
|
c->held_count = 0;
|
||||||
|
c->held_bytes = 0;
|
||||||
|
c->gap_deadline = 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void gc_slots(udps_client_t *c, double now) {
|
static void gc_slots(udps_client_t *c, double now) {
|
||||||
@@ -596,7 +852,7 @@ static void deliver_payload(udps_client_t *c, uint8_t type, const uint8_t *pl,
|
|||||||
if (type == UDPS_PKT_CONFIG) {
|
if (type == UDPS_PKT_CONFIG) {
|
||||||
(void)decode_config(c, pl, len);
|
(void)decode_config(c, pl, len);
|
||||||
} else {
|
} else {
|
||||||
(void)decode_data(c, pl, len, counter, recv_time);
|
(void)order_data(c, pl, len, counter, recv_time);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -895,8 +1151,49 @@ static int connect_multicast(udps_client_t *c) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** TCP transport: connect, then SUBSCRIBE when a stream name is set. */
|
||||||
|
static int connect_tcp(udps_client_t *c) {
|
||||||
|
struct timeval tv;
|
||||||
|
size_t name_len = strlen(c->stream_name);
|
||||||
|
|
||||||
|
c->tcp_fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (c->tcp_fd < 0) {
|
||||||
|
return fail(c, "TCP socket() failed: %s", strerror(errno));
|
||||||
|
}
|
||||||
|
set_recv_buffer(c->tcp_fd, c->cfg.recv_buffer_bytes);
|
||||||
|
/* Linux bounds a blocking connect() with SO_SNDTIMEO, so an unreachable host
|
||||||
|
* cannot hold the polling thread for minutes. */
|
||||||
|
tv.tv_sec = 3;
|
||||||
|
tv.tv_usec = 0;
|
||||||
|
(void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, (socklen_t)sizeof tv);
|
||||||
|
if (connect(c->tcp_fd, (struct sockaddr *)&c->server_sa,
|
||||||
|
(socklen_t)sizeof c->server_sa) != 0) {
|
||||||
|
int e = errno;
|
||||||
|
close_sockets(c);
|
||||||
|
return fail(c, "TCP connect to %s:%u failed: %s", c->server_addr,
|
||||||
|
(unsigned)c->cfg.server_port, strerror(e));
|
||||||
|
}
|
||||||
|
/* A half-received frame must not wedge the poll loop (read_exact_tcp blocks). */
|
||||||
|
tv.tv_sec = 1;
|
||||||
|
(void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, (socklen_t)sizeof tv);
|
||||||
|
|
||||||
|
if (name_len > 0u) {
|
||||||
|
uint8_t pkt[UDPS_HEADER_SIZE + 255u];
|
||||||
|
build_header(pkt, UDPS_PKT_SUBSCRIBE, 0u, 0u, 1u, (uint32_t)name_len);
|
||||||
|
memcpy(pkt + UDPS_HEADER_SIZE, c->stream_name, name_len);
|
||||||
|
if (send(c->tcp_fd, pkt, UDPS_HEADER_SIZE + name_len, MSG_NOSIGNAL) !=
|
||||||
|
(ssize_t)(UDPS_HEADER_SIZE + name_len)) {
|
||||||
|
int e = errno;
|
||||||
|
close_sockets(c);
|
||||||
|
return fail(c, "SUBSCRIBE failed: %s", strerror(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
static int do_connect(udps_client_t *c) {
|
static int do_connect(udps_client_t *c) {
|
||||||
int rc = (c->mcast_group[0] != '\0') ? connect_multicast(c)
|
int rc = c->cfg.use_tcp ? connect_tcp(c)
|
||||||
|
: (c->mcast_group[0] != '\0') ? connect_multicast(c)
|
||||||
: connect_unicast(c);
|
: connect_unicast(c);
|
||||||
if (rc != 0) {
|
if (rc != 0) {
|
||||||
c->disconnect_t = now_mono();
|
c->disconnect_t = now_mono();
|
||||||
@@ -906,6 +1203,7 @@ static int do_connect(udps_client_t *c) {
|
|||||||
c->last_data = now_mono();
|
c->last_data = now_mono();
|
||||||
c->last_keepalive = c->last_data;
|
c->last_keepalive = c->last_data;
|
||||||
c->have_counter = 0;
|
c->have_counter = 0;
|
||||||
|
c->next_counter = 0u;
|
||||||
slot_reset_all(c);
|
slot_reset_all(c);
|
||||||
if (c->ever_connected) {
|
if (c->ever_connected) {
|
||||||
c->stats.reconnects++;
|
c->stats.reconnects++;
|
||||||
@@ -919,7 +1217,10 @@ static void do_disconnect(udps_client_t *c, const char *why) {
|
|||||||
if (!c->connected) {
|
if (!c->connected) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!c->cfg.use_tcp) {
|
||||||
|
/* TCP: a write to a peer that closed would raise SIGPIPE; the server sees the close itself. */
|
||||||
(void)send_control(c, UDPS_PKT_DISCONNECT);
|
(void)send_control(c, UDPS_PKT_DISCONNECT);
|
||||||
|
}
|
||||||
close_sockets(c);
|
close_sockets(c);
|
||||||
c->connected = 0;
|
c->connected = 0;
|
||||||
c->disconnect_t = now_mono();
|
c->disconnect_t = now_mono();
|
||||||
@@ -956,7 +1257,7 @@ static int read_tcp_frame(udps_client_t *c) {
|
|||||||
if (udps_parse_header(c->rxbuf, UDPS_HEADER_SIZE, &h) != 0) {
|
if (udps_parse_header(c->rxbuf, UDPS_HEADER_SIZE, &h) != 0) {
|
||||||
return -1; /* desynchronised: the stream cannot be resynced */
|
return -1; /* desynchronised: the stream cannot be resynced */
|
||||||
}
|
}
|
||||||
if (h.payload_bytes > (uint32_t)(UDPS_RX_BUF_BYTES - UDPS_HEADER_SIZE)) {
|
if (h.payload_bytes > c->rxbuf_cap - UDPS_HEADER_SIZE) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
if (h.payload_bytes > 0u &&
|
if (h.payload_bytes > 0u &&
|
||||||
@@ -981,6 +1282,7 @@ void udps_client_config_init(udps_client_config_t *cfg) {
|
|||||||
cfg->keepalive_interval_s = 15.0; /* server evicts silent clients at 30 s */
|
cfg->keepalive_interval_s = 15.0; /* server evicts silent clients at 30 s */
|
||||||
cfg->recv_buffer_bytes = 4u * 1024u * 1024u;
|
cfg->recv_buffer_bytes = 4u * 1024u * 1024u;
|
||||||
cfg->max_packet_bytes = 1u * 1024u * 1024u;
|
cfg->max_packet_bytes = 1u * 1024u * 1024u;
|
||||||
|
cfg->reorder_window_s = UDPS_REORDER_WINDOW_S_DEFAULT;
|
||||||
}
|
}
|
||||||
|
|
||||||
static void copy_str(char *dst, size_t cap, const char *src) {
|
static void copy_str(char *dst, size_t cap, const char *src) {
|
||||||
@@ -992,6 +1294,15 @@ static void copy_str(char *dst, size_t cap, const char *src) {
|
|||||||
dst[cap - 1u] = '\0';
|
dst[cap - 1u] = '\0';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void udps_client_set_datagram_cb(udps_client_t *client,
|
||||||
|
udps_datagram_cb cb, void *user) {
|
||||||
|
if (client == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client->datagram_cb = cb;
|
||||||
|
client->datagram_user = user;
|
||||||
|
}
|
||||||
|
|
||||||
udps_client_t *udps_client_create(const udps_client_config_t *cfg) {
|
udps_client_t *udps_client_create(const udps_client_config_t *cfg) {
|
||||||
udps_client_t *c;
|
udps_client_t *c;
|
||||||
|
|
||||||
@@ -1002,17 +1313,28 @@ udps_client_t *udps_client_create(const udps_client_config_t *cfg) {
|
|||||||
if (c == NULL) {
|
if (c == NULL) {
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
if (cfg->use_tcp && cfg->stream_name != NULL && strlen(cfg->stream_name) > 255u) {
|
||||||
|
free(c);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
c->cfg = *cfg;
|
c->cfg = *cfg;
|
||||||
c->udp_fd = -1;
|
c->udp_fd = -1;
|
||||||
c->tcp_fd = -1;
|
c->tcp_fd = -1;
|
||||||
copy_str(c->server_addr, sizeof c->server_addr, cfg->server_addr);
|
copy_str(c->server_addr, sizeof c->server_addr, cfg->server_addr);
|
||||||
copy_str(c->mcast_group, sizeof c->mcast_group, cfg->multicast_group);
|
copy_str(c->mcast_group, sizeof c->mcast_group, cfg->multicast_group);
|
||||||
copy_str(c->iface_addr, sizeof c->iface_addr, cfg->interface_addr);
|
copy_str(c->iface_addr, sizeof c->iface_addr, cfg->interface_addr);
|
||||||
|
copy_str(c->stream_name, sizeof c->stream_name, cfg->stream_name);
|
||||||
/* The config's string pointers must not be used again: they belong to the
|
/* The config's string pointers must not be used again: they belong to the
|
||||||
* caller and the copies above are what the client works from. */
|
* caller and the copies above are what the client works from. */
|
||||||
c->cfg.server_addr = c->server_addr;
|
c->cfg.server_addr = c->server_addr;
|
||||||
c->cfg.multicast_group = (c->mcast_group[0] != '\0') ? c->mcast_group : NULL;
|
c->cfg.multicast_group = (c->mcast_group[0] != '\0') ? c->mcast_group : NULL;
|
||||||
c->cfg.interface_addr = (c->iface_addr[0] != '\0') ? c->iface_addr : NULL;
|
c->cfg.interface_addr = (c->iface_addr[0] != '\0') ? c->iface_addr : NULL;
|
||||||
|
c->cfg.stream_name = c->stream_name;
|
||||||
|
if (c->cfg.use_tcp) {
|
||||||
|
c->cfg.multicast_group = NULL;
|
||||||
|
c->mcast_group[0] = '\0';
|
||||||
|
c->cfg.reorder_window_s = 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
if (c->cfg.max_packet_bytes == 0u) {
|
if (c->cfg.max_packet_bytes == 0u) {
|
||||||
c->cfg.max_packet_bytes = 1u * 1024u * 1024u;
|
c->cfg.max_packet_bytes = 1u * 1024u * 1024u;
|
||||||
@@ -1029,7 +1351,13 @@ udps_client_t *udps_client_create(const udps_client_config_t *cfg) {
|
|||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
||||||
c->rxbuf = (uint8_t *)malloc(UDPS_RX_BUF_BYTES);
|
/* A UDP datagram is at most UDPS_MAX_DATAGRAM_BYTES; a TCP frame is not fragmented
|
||||||
|
* and may be as large as max_packet_bytes (MARTe's TCPStreamer allows 1 MiB). */
|
||||||
|
c->rxbuf_cap = UDPS_RX_BUF_BYTES;
|
||||||
|
if (c->cfg.use_tcp && (size_t)c->cfg.max_packet_bytes + UDPS_HEADER_SIZE > c->rxbuf_cap) {
|
||||||
|
c->rxbuf_cap = (size_t)c->cfg.max_packet_bytes + UDPS_HEADER_SIZE;
|
||||||
|
}
|
||||||
|
c->rxbuf = (uint8_t *)malloc(c->rxbuf_cap);
|
||||||
if (c->rxbuf == NULL) {
|
if (c->rxbuf == NULL) {
|
||||||
free(c);
|
free(c);
|
||||||
return NULL;
|
return NULL;
|
||||||
@@ -1048,6 +1376,9 @@ void udps_client_destroy(udps_client_t *c) {
|
|||||||
for (i = 0; i < UDPS_MAX_SLOTS; i++) {
|
for (i = 0; i < UDPS_MAX_SLOTS; i++) {
|
||||||
free(c->slots[i].payload);
|
free(c->slots[i].payload);
|
||||||
}
|
}
|
||||||
|
for (i = 0; i < UDPS_REORDER_SLOTS; i++) {
|
||||||
|
free(c->held[i].buf);
|
||||||
|
}
|
||||||
free(c->sigs);
|
free(c->sigs);
|
||||||
free(c->valbuf);
|
free(c->valbuf);
|
||||||
free(c->vals);
|
free(c->vals);
|
||||||
@@ -1151,6 +1482,14 @@ int udps_client_poll(udps_client_t *c, int timeout_ms) {
|
|||||||
}
|
}
|
||||||
budget = clamp_deadline(budget, c->cfg.silence_timeout_s, now - c->last_data);
|
budget = clamp_deadline(budget, c->cfg.silence_timeout_s, now - c->last_data);
|
||||||
|
|
||||||
|
/* A held packet must flush on time even when nothing further arrives: at 10 pkt/s
|
||||||
|
* the next arrival is 100 ms away, which is five times the whole budget. */
|
||||||
|
if (c->gap_deadline > 0.0) {
|
||||||
|
double left = c->gap_deadline - now_wall();
|
||||||
|
if (left < 0.0) { left = 0.0; }
|
||||||
|
if (left < budget) { budget = left; }
|
||||||
|
}
|
||||||
|
|
||||||
FD_ZERO(&rset);
|
FD_ZERO(&rset);
|
||||||
maxfd = -1;
|
maxfd = -1;
|
||||||
if (c->udp_fd >= 0 && c->udp_fd < FD_SETSIZE) {
|
if (c->udp_fd >= 0 && c->udp_fd < FD_SETSIZE) {
|
||||||
@@ -1204,6 +1543,11 @@ int udps_client_poll(udps_client_t *c, int timeout_ms) {
|
|||||||
}
|
}
|
||||||
drained++;
|
drained++;
|
||||||
c->last_data = now_mono();
|
c->last_data = now_mono();
|
||||||
|
/* The raw datagram, before anything reads out of rxbuf: this is the
|
||||||
|
* only place a malformed or duplicate datagram is still visible. */
|
||||||
|
if (c->datagram_cb != NULL) {
|
||||||
|
c->datagram_cb(c->rxbuf, (size_t)n, now_wall(), c->datagram_user);
|
||||||
|
}
|
||||||
if ((size_t)n >= UDPS_HEADER_SIZE) {
|
if ((size_t)n >= UDPS_HEADER_SIZE) {
|
||||||
handle_packet(c, c->rxbuf, (size_t)n);
|
handle_packet(c, c->rxbuf, (size_t)n);
|
||||||
}
|
}
|
||||||
@@ -1221,6 +1565,10 @@ int udps_client_poll(udps_client_t *c, int timeout_ms) {
|
|||||||
}
|
}
|
||||||
gc_slots(c, now_wall());
|
gc_slots(c, now_wall());
|
||||||
|
|
||||||
|
if (c->gap_deadline > 0.0 && now_wall() >= c->gap_deadline) {
|
||||||
|
(void)commit_gap(c);
|
||||||
|
}
|
||||||
|
|
||||||
if (c->cfg.silence_timeout_s > 0.0 &&
|
if (c->cfg.silence_timeout_s > 0.0 &&
|
||||||
(now - c->last_data) >= c->cfg.silence_timeout_s) {
|
(now - c->last_data) >= c->cfg.silence_timeout_s) {
|
||||||
do_disconnect(c, "server went silent");
|
do_disconnect(c, "server went silent");
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ enum {
|
|||||||
UDPS_PKT_CONFIG = 1, /**< Server -> client: signal metadata. */
|
UDPS_PKT_CONFIG = 1, /**< Server -> client: signal metadata. */
|
||||||
UDPS_PKT_ACK = 2, /**< Client -> server: keepalive. */
|
UDPS_PKT_ACK = 2, /**< Client -> server: keepalive. */
|
||||||
UDPS_PKT_CONNECT = 3, /**< Client -> server: open a session. */
|
UDPS_PKT_CONNECT = 3, /**< Client -> server: open a session. */
|
||||||
UDPS_PKT_DISCONNECT = 4 /**< Either direction: close a session. */
|
UDPS_PKT_DISCONNECT = 4, /**< Either direction: close a session. */
|
||||||
|
UDPS_PKT_SUBSCRIBE = 5 /**< Client -> server (TCP stream service): payload = stream name. */
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Sample type codes (udps_signal_t::type_code). */
|
/** Sample type codes (udps_signal_t::type_code). */
|
||||||
@@ -150,6 +151,13 @@ typedef struct {
|
|||||||
* range the next frame claims.
|
* range the next frame claims.
|
||||||
*/
|
*/
|
||||||
uint32_t lost;
|
uint32_t lost;
|
||||||
|
/**
|
||||||
|
* 1 when this frame's counter is behind the sequence already delivered: it lost
|
||||||
|
* the race past the reorder window and the consumer has a hole where it belongs.
|
||||||
|
* @c lost is 0 for such a frame -- it is not missing anything, it *is* what was
|
||||||
|
* missing -- so a consumer sizes its backfill from @c counter, not from @c lost.
|
||||||
|
*/
|
||||||
|
uint8_t late;
|
||||||
uint64_t hrt; /**< Producer's high-resolution timer at send. */
|
uint64_t hrt; /**< Producer's high-resolution timer at send. */
|
||||||
double recv_time; /**< Wall-clock seconds (CLOCK_REALTIME) at arrival. */
|
double recv_time; /**< Wall-clock seconds (CLOCK_REALTIME) at arrival. */
|
||||||
uint8_t publish_mode; /**< UDPS_PUBLISH_*. */
|
uint8_t publish_mode; /**< UDPS_PUBLISH_*. */
|
||||||
@@ -180,6 +188,30 @@ typedef struct {
|
|||||||
* time base older than data already handed over.
|
* time base older than data already handed over.
|
||||||
*/
|
*/
|
||||||
uint64_t stale_packets;
|
uint64_t stale_packets;
|
||||||
|
/**
|
||||||
|
* DATA packets that were held waiting for an earlier counter and subsequently
|
||||||
|
* delivered via @c drain_held(). This includes a packet released by the
|
||||||
|
* reorder window expiring: that packet arrives alongside a non-zero @c lost
|
||||||
|
* and therefore alongside a gap, so @c late_resequenced is not by itself a
|
||||||
|
* count of gaps avoided. Compare @c late_delivered, which counts packets
|
||||||
|
* that lost the race past an already-expired window entirely.
|
||||||
|
*/
|
||||||
|
uint64_t late_resequenced;
|
||||||
|
/**
|
||||||
|
* DATA packets delivered behind the sequence because the reorder window had
|
||||||
|
* already closed on them. The consumer has a hole for these and may fill it;
|
||||||
|
* before the window existed they were counted in @c stale_packets and dropped.
|
||||||
|
*/
|
||||||
|
uint64_t late_delivered;
|
||||||
|
/**
|
||||||
|
* Times a DATA packet needed a slot in the reorder ring and found none, so the
|
||||||
|
* current gap was committed early to make room. Every such packet is still held
|
||||||
|
* or delivered -- none is dropped -- but the window it was given was shorter
|
||||||
|
* than @c reorder_window_s asked for. A non-zero reading says the ring is
|
||||||
|
* undersized for the stream's reordering depth, which is a reading and not a
|
||||||
|
* mystery; it is the only place that shows up.
|
||||||
|
*/
|
||||||
|
uint64_t reorder_overflows;
|
||||||
uint64_t reconnects;
|
uint64_t reconnects;
|
||||||
} udps_stats_t;
|
} udps_stats_t;
|
||||||
|
|
||||||
@@ -206,6 +238,20 @@ typedef void (*udps_data_cb)(const udps_frame_t *frame, void *user);
|
|||||||
/** Called on connection state changes and on recoverable errors. */
|
/** Called on connection state changes and on recoverable errors. */
|
||||||
typedef void (*udps_event_cb)(udps_event_t event, const char *detail, void *user);
|
typedef void (*udps_event_cb)(udps_event_t event, const char *detail, void *user);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Largest datagram recv() can deliver on the client's socket, and therefore the
|
||||||
|
* most a raw-datagram observer ever sees in one call.
|
||||||
|
*/
|
||||||
|
#define UDPS_MAX_DATAGRAM_BYTES (65535 + 17)
|
||||||
|
|
||||||
|
/** Called for every datagram as recv() returned it, before the magic check,
|
||||||
|
* reassembly or resequencing: the bytes a capture has to store, and the only
|
||||||
|
* place malformed or duplicate datagrams are still visible. Runs on the polling
|
||||||
|
* thread, inside udps_client_poll(), like every other callback. `bytes` is
|
||||||
|
* valid until the callback returns. */
|
||||||
|
typedef void (*udps_datagram_cb)(const uint8_t *bytes, size_t len,
|
||||||
|
double recv_time, void *user);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Transport configuration.
|
* @brief Transport configuration.
|
||||||
*
|
*
|
||||||
@@ -226,6 +272,28 @@ typedef struct {
|
|||||||
double keepalive_interval_s;/**< Unicast ACK period. 0 disables. */
|
double keepalive_interval_s;/**< Unicast ACK period. 0 disables. */
|
||||||
uint32_t recv_buffer_bytes; /**< SO_RCVBUF; large bursts need a large value. */
|
uint32_t recv_buffer_bytes; /**< SO_RCVBUF; large bursts need a large value. */
|
||||||
uint32_t max_packet_bytes; /**< Ceiling on one reassembled payload. */
|
uint32_t max_packet_bytes; /**< Ceiling on one reassembled payload. */
|
||||||
|
/**
|
||||||
|
* How long a packet may be held waiting for an earlier counter, in seconds.
|
||||||
|
*
|
||||||
|
* A *contiguous* arrival is never held, so this is the latency a gap costs and
|
||||||
|
* not a latency the stream pays. 0 disables resequencing entirely and restores
|
||||||
|
* the drop-on-late behaviour. Default 0.020.
|
||||||
|
*/
|
||||||
|
double reorder_window_s;
|
||||||
|
/**
|
||||||
|
* Non-zero selects the TCP transport (a MARTe2 TCPStreamer / TCPStreamService):
|
||||||
|
* @c server_port is the TCP port, no CONNECT or DISCONNECT frame is sent, the
|
||||||
|
* server streams CONFIG then DATA on connect, and the reorder window is forced
|
||||||
|
* to 0 (TCP is ordered, so a counter gap is a server-side drop). @c multicast_group,
|
||||||
|
* @c interface_addr and @c data_port are ignored.
|
||||||
|
*/
|
||||||
|
int use_tcp;
|
||||||
|
/**
|
||||||
|
* TCP mode only: when non-empty (1..255 bytes) a SUBSCRIBE frame naming this stream is
|
||||||
|
* sent right after every connect, as a TCPStreamService requires. A plain
|
||||||
|
* TCPStreamer ignores it. NULL or "" sends nothing.
|
||||||
|
*/
|
||||||
|
const char *stream_name;
|
||||||
} udps_client_config_t;
|
} udps_client_config_t;
|
||||||
|
|
||||||
/** Fills @p cfg with the defaults documented in Docs/UDPS-C-Client.md. */
|
/** Fills @p cfg with the defaults documented in Docs/UDPS-C-Client.md. */
|
||||||
@@ -247,6 +315,12 @@ void udps_client_set_callbacks(udps_client_t *client,
|
|||||||
udps_event_cb on_event,
|
udps_event_cb on_event,
|
||||||
void *user);
|
void *user);
|
||||||
|
|
||||||
|
/** Installs the raw-datagram observer. Separate from
|
||||||
|
* udps_client_set_callbacks(), whose signature has call sites and which a
|
||||||
|
* capture hook is no reason to change. May be NULL. */
|
||||||
|
void udps_client_set_datagram_cb(udps_client_t *client,
|
||||||
|
udps_datagram_cb cb, void *user);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Drives the client: connects if needed, then waits for and processes
|
* @brief Drives the client: connects if needed, then waits for and processes
|
||||||
* packets for at most @p timeout_ms milliseconds.
|
* packets for at most @p timeout_ms milliseconds.
|
||||||
|
|||||||
@@ -6,9 +6,23 @@ 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
|
*The main window with a source streaming: the header bar above (the window control — `2 s` — first on
|
||||||
status line directly under it, and the toolbar above. The window control — `2 s` — is the first thing
|
the left), the plot, the badge bar under it with one badge per signal, the status line at the foot, and
|
||||||
on the left.*
|
the dock on the right with the cursor and measurement readouts and the events block.*
|
||||||
|
|
||||||
|
The window is a plot with three things around it:
|
||||||
|
|
||||||
|
- the **badge bar** under the plot: a badge for each signal you have switched on, a `+` to add one
|
||||||
|
(past six they fold into one `N signals` badge), and, on the right, **Trigger** and — only for a
|
||||||
|
source with controls of its own — **Source**;
|
||||||
|
- the **side panel**, which a badge opens over the plot's right edge, one page at a time: the trigger,
|
||||||
|
a signal, the list of signals to add, the cursors, the measurements, the events, or a source's own
|
||||||
|
controls. A second click on the badge, `Esc`, **Close** or a click on the plot closes it, and it
|
||||||
|
never resizes the plot;
|
||||||
|
- the **dock** on the right, which the dock button in the header bar (`s`) shows and hides: read-only
|
||||||
|
**Readouts** of the cursors and of the measurements, each with an **Edit** button that opens its
|
||||||
|
page, and an **Events** block. With the dock hidden, or in a narrow window where the dock slides over the plot, the readouts show in a
|
||||||
|
one-line strip under the plot instead (the cursors' deltas and each measurement's value; **Cursors** and **Measures** open the pages); the strip is there only while the cursors are on or there is a measurement.
|
||||||
|
|
||||||
## 1. Connecting
|
## 1. Connecting
|
||||||
|
|
||||||
@@ -30,7 +44,7 @@ Every launch is its own process, so running two scopes side by side is fine.
|
|||||||
|
|
||||||
## 2. The status line
|
## 2. The status line
|
||||||
|
|
||||||
Directly under the plot, it reads `● <state> · <rate> · <window>`, and the ⓘ beside it opens the
|
At the foot of the window, under the badge bar, 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.
|
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`,
|
- **The state word is one word** from a fixed set: `live`, `paused`, `armed`, `capturing`,
|
||||||
@@ -44,14 +58,43 @@ detail: packets, frames, gaps, per-channel sample counts, the buffer's span and
|
|||||||
|
|
||||||
## 3. Signals
|
## 3. Signals
|
||||||
|
|
||||||
The signal panel lists every signal the CONFIG described, with a switch each. **Signals start
|
The **`+` badge** (or `g`) opens the **Signals** page, which lists what can be added: the signals the
|
||||||
disabled — enabling one is what starts acquiring it**, and a signal nobody enables costs nothing.
|
CONFIG described that are not acquired yet, each with a tick. **Signals start disabled — ticking one
|
||||||
Drag a signal from the panel onto a plot row to add a trace; drag it again to another row to plot the
|
is what starts acquiring it**, and a signal nobody enables costs nothing. Its badge then appears in
|
||||||
same signal twice. The colour is per *signal*, so the same signal is the same colour wherever it
|
the badge bar. Drag the badge onto a plot row to add a trace; drag it again to another row to plot
|
||||||
appears.
|
the same signal twice, or onto another badge to reorder the badges. 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
|
Clicking a signal's badge opens its own page. Directly under the name are its **Unit**, **Gain** and
|
||||||
measurements and the export carry the same numbers the plot shows.
|
**Offset** — each with a pencil button that edits that one value — and then what the stream declared about
|
||||||
|
it (type, size, range, sample rate, timing). Below come the colour and the **Plot on** buttons for the
|
||||||
|
rows. Gain and offset are applied at ingest, so the measurements and the export carry the same numbers the
|
||||||
|
plot shows; a renamed unit is used there too and is saved with the session.
|
||||||
|
|
||||||
|
### Splitting a row
|
||||||
|
|
||||||
|
Press `l` (or pick **Layout editor** from the overflow menu) to arrange the plots: a **`×`** at the top
|
||||||
|
left of each plot removes it, a **`+`** on each boundary between rows adds a row, and a **Done** button
|
||||||
|
in the plot's top-right corner leaves the editor. The plots can also be put **side by side**: a row holds
|
||||||
|
up to **4** plots, and the figure up to **8** in all.
|
||||||
|
|
||||||
|
- **To split a row**, click the `+` at its left or right edge, or the `+` between two of its plots. The
|
||||||
|
new plot is empty and the plots after it keep their traces. The `+` is dim at 4 plots in the row or 8
|
||||||
|
in the figure.
|
||||||
|
- **To resize**, drag the double-headed arrow between two plots left or right. The two neighbours trade width (each
|
||||||
|
stays at least about 96 px wide); the sizes are saved with the session.
|
||||||
|
- **To remove a plot**, click its `×`. In a split row it removes just that plot and its traces; in a row
|
||||||
|
of one plot it removes the row.
|
||||||
|
|
||||||
|
Every plot shares **one time window**: panning or zooming in any of them moves them all, the crosshair
|
||||||
|
sits at the same time in each, and the vertical cursors are the same two times in each. The plots of a split
|
||||||
|
row also share **one y range**: the y labels are drawn at the left of the first plot only, the
|
||||||
|
horizontal rulers are the same two values in every plot, and a y zoom, pan, fit or fixed range in any of
|
||||||
|
them applies to the whole row. The time labels sit under the bottom row, and
|
||||||
|
under any row above it whose columns differ from the row below. A plot of a split row is named by its row and a letter, `2a` to `2d`: that is what the signals
|
||||||
|
page's **Plot on** buttons and the status line call it, and a signal dragged
|
||||||
|
from its badge goes onto the plot under the pointer. The arrangement is saved per source, and a session
|
||||||
|
saved before rows could be split opens with one plot per row, as it was.
|
||||||
|
|
||||||
## 4. The window and navigation
|
## 4. The window and navigation
|
||||||
|
|
||||||
@@ -65,6 +108,12 @@ window change moves the trigger with it.
|
|||||||
- **Ctrl+drag** frames x to a box, **Alt+drag** y.
|
- **Ctrl+drag** frames x to a box, **Alt+drag** y.
|
||||||
- **Fit to data** (menu) auto-scales both axes and frames the whole buffered range.
|
- **Fit to data** (menu) auto-scales both axes and frames the whole buffered range.
|
||||||
|
|
||||||
|
Each plot has its own **right-click menu**, in the same compact row style as the app's menu:
|
||||||
|
**Y axis: auto scale**, **Y axis: fixed range...**, **Fit X axis**, **Fit all** (`f`), and
|
||||||
|
**Remove signal** — which swaps the menu to one entry per trace on that plot plus *All traces on
|
||||||
|
this plot*, with a **Back** row to return. That is how a trace comes off a row without going through
|
||||||
|
the layout editor.
|
||||||
|
|
||||||
The view is bounded by the **time window**, not by the buffer: zooming out stops at the span you set,
|
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
|
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
|
signal is enabled — the window is left empty on its left, which is what a scope that has just started
|
||||||
@@ -77,8 +126,9 @@ should look like.
|
|||||||
*Single mode with a capture held: the axis is the event's own `[-pre, +post]`, the trigger marker
|
*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`.*
|
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,
|
The trigger button in the header bar (`t`) turns trigger mode on. The **Trigger** badge opens the
|
||||||
and Arm/Disarm. Selecting a signal for the trigger enables it.
|
trigger page, which has the signal, the condition, 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
|
- **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.
|
fills in view; the axis does not move.
|
||||||
@@ -88,6 +138,10 @@ and Arm/Disarm. Selecting a signal for the trigger enables it.
|
|||||||
when you disarm is the picture you keep.
|
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
|
- 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.
|
jumps still gets the window it asked for.
|
||||||
|
- **Events.** With *record events* on (trigger page), each latch is stored. The dock's **Events** block
|
||||||
|
lists them newest first and **one click replays** one; **Manage** opens the full list as a page,
|
||||||
|
where a click selects, a double click replays, and **Compare**, **Accumulate** and **Live** act on
|
||||||
|
the selection or go back to the stream. The block is dimmed until something is recorded.
|
||||||
|
|
||||||
## 6. Pause
|
## 6. Pause
|
||||||
|
|
||||||
@@ -98,22 +152,24 @@ does not walk it off its snapshot.
|
|||||||
|
|
||||||
## 7. Cursors
|
## 7. Cursors
|
||||||
|
|
||||||
The cursors toolbar toggle switches on two instruments per row:
|
The cursors toggle in the header bar (`c`) switches on two instruments per row:
|
||||||
|
|
||||||
- **vertical** — two times, shared by every row;
|
- **vertical** — two times, shared by every row (and every plot of a split row);
|
||||||
- **horizontal** — two rulers per row;
|
- **horizontal** — two rulers per row (every plot of a split row shows the same two values);
|
||||||
- **both** — the two together;
|
- **both** — the two together;
|
||||||
- **mix** — the two times, read off each *trace* where they cross it.
|
- **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
|
The dock reports each trace's values at the markers; **Edit** beside **Cursors** opens the page with
|
||||||
window's edges. Values read `--` when a marker is past the newest sample and `partial` when only one
|
the mode and **Reset**, which 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.
|
marker is over data.
|
||||||
|
|
||||||
## 8. Measurements
|
## 8. Measurements
|
||||||
|
|
||||||
The measurements pane computes statistics of the **visible window** of a signal: average, top,
|
A measurement is a statistic of the **visible window** of a signal: average, top, bottom, min, max,
|
||||||
bottom, min, max, peak to peak, amplitude, pulse duration, period and frequency. `+` adds one, the
|
peak to peak, amplitude, pulse duration, period and frequency. `m`, or **Edit** beside
|
||||||
row's pencil edits it, and **show on plot** draws its result as a ruler (levels) or markers (times).
|
**Measurements** in the dock, opens the page; its `+` adds one, a click on a row (in the page or in
|
||||||
|
the dock) edits it, and **show on plot** draws its result as a ruler (levels) or markers (times).
|
||||||
|
The dock shows each measurement's value.
|
||||||
|
|
||||||
`top` and `bottom` take a *split* as a share of the window's own range — the percentage travels
|
`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
|
between signals where a threshold in volts would not. `period` and `frequency` deliberately have no
|
||||||
@@ -121,7 +177,7 @@ split: they count rising crossings of the mid level, one edge per cycle, and a s
|
|||||||
setting you can only get wrong.
|
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
|
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
|
it stops, and once per mode change — so a live view cannot cost you frames. The cost is
|
||||||
reported in the exit dump with the app's self-check.
|
reported in the exit dump with the app's self-check.
|
||||||
|
|
||||||
## 9. Math channels
|
## 9. Math channels
|
||||||
@@ -133,13 +189,14 @@ the square on the right. The orange dot marks the node that carries the channel'
|
|||||||
|
|
||||||
A math channel is derived from the others and is then an ordinary channel: enable it and it is
|
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
|
acquired, plotted, measured and exported like a streamed signal, and another math channel can use it
|
||||||
as a source.
|
as a source. The **Signals** page lists them under a `MATH` heading whose `+` creates one; a math
|
||||||
|
channel's own page has **Edit graph** and **Delete**.
|
||||||
|
|
||||||
Operations: `channel` (a source), `gain + offset`, `abs`, `sqrt`, `clamp`, `low pass`, `high pass`,
|
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`.
|
`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** 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
|
- **Drag from an output square to 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.
|
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.
|
- **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;
|
- The **output is the last node** of a graph you build top to bottom, and the orange dot says which;
|
||||||
@@ -174,19 +231,24 @@ per sample, `time`, `stream_time`, then one column per signal.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `space` | pause / resume |
|
| `space` | pause / resume |
|
||||||
| `a` | arm / disarm the trigger |
|
| `a` | arm / disarm the trigger |
|
||||||
| `t` | the trigger panel |
|
| `t` | enable / disable trigger mode |
|
||||||
| `l` | the layout editor |
|
| `c` | enable / disable the cursors |
|
||||||
| `g` | go live |
|
| `m` | open or close the Measurements page |
|
||||||
| `f` | fit to data |
|
| `g` | open or close the Signals page (the `+` badge opens it too) |
|
||||||
| `d` | add the first free signal to the focused row |
|
| `l` | show / hide the layout editor |
|
||||||
| `c` | cursors on/off |
|
| `s` | show / hide the dock |
|
||||||
| `m` | measurements pane |
|
| `Esc` | close the slide-over page (the guided tour takes it first) |
|
||||||
| `e` | export |
|
| `f` | fit to data (the plot's own key) |
|
||||||
| `?` | shortcuts |
|
| `Ctrl+E` | export visible range |
|
||||||
|
| `F1` | open the manual |
|
||||||
|
| `Ctrl+/` | open the shortcut overview |
|
||||||
|
| `Ctrl+T` | start the guided tour |
|
||||||
|
| `Ctrl+H` | turn the contextual help on or off |
|
||||||
|
| `Ctrl+Q` | quit |
|
||||||
|
|
||||||
`Ctrl+E` exports, `Ctrl+Q` quits, `Ctrl+plus`/`Ctrl+minus` zoom x and `Alt+plus`/`Alt+minus` zoom y.
|
The plot also answers `+`/`-` (zoom both axes), `Ctrl+plus`/`Ctrl+minus` (zoom x only),
|
||||||
A keystroke is ignored while a text field has focus, so typing in the window control never arms the
|
`Alt+plus`/`Alt+minus` (zoom y only) and the arrow keys (pan). A keystroke is ignored while a text
|
||||||
trigger.
|
field has focus, so typing in the window control never arms the trigger.
|
||||||
|
|
||||||
## 13. Troubleshooting
|
## 13. Troubleshooting
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
# 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 a `DataSource` and calls
|
||||||
|
`source->poll(50)`; 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.
|
||||||
|
|
||||||
|
### Sources
|
||||||
|
|
||||||
|
What feeds `Acquisition` is a `DataSource`, one of the registered *kinds*.
|
||||||
|
|
||||||
|
- The kinds live in `src/source/`. `register_builtin_sources()` fills the registry explicitly --
|
||||||
|
there is no static-initialiser registration, which a linker may drop -- and a future `dlopen`
|
||||||
|
loader would only call `register_kind()`.
|
||||||
|
- A `DataSource` delivers into `Acquisition` (a `SourceSink`) from inside `poll()`. `Frame` is
|
||||||
|
source-neutral, and its pointers are valid **only during `on_data`**: copy what is kept.
|
||||||
|
- The GTK thread reaches a source **only** through `DataSource::post(Command)`; the queue is drained
|
||||||
|
at the top of `poll()`. A panel never calls a source directly.
|
||||||
|
- `UdpsSource` wraps `deps/udpsclient` and takes a `Transport` (UDP or TCP); `udps_*` names appear only in
|
||||||
|
`src/source/udps_*.cpp` (`grep -rn "udps_" src` outside those files is a leak of the old coupling).
|
||||||
|
- **The TCPStreamer kind (`tcps`) is `UdpsSource` with `Transport::Tcp`**, not a new `DataSource`: the
|
||||||
|
transport argument sets `use_tcp` / `stream_name` in the client config, defaults the port to 44500 and
|
||||||
|
ignores multicast and interface. `src/source/tcps_kind.cpp` is only the form and the identity (`host:port` or
|
||||||
|
`host:port/stream`). One stream per connection (no multiplexed `SUBSCRIBE` list: a source is
|
||||||
|
single-timebase; merge on the server with `TCPStreamJoin`), and the form refuses a name with a comma.
|
||||||
|
`has_datagrams()` is false, so the capture button stays disabled. `Disconnected` follows the client's 2 s
|
||||||
|
silence timeout or a closed connection, and it reconnects every 0.5 s; a stream whose frames are further
|
||||||
|
apart than 2 s therefore reconnects continually, as UDPS does.
|
||||||
|
- The frame is **single-timebase**. A multi-timebase source (ABScope) will change the interface and
|
||||||
|
the data path together; do not bend one frame to carry two clocks.
|
||||||
|
- A kind's capability panels are pages of the slide-over (`slideover_add_page`), named `source-<id>`. A disconnected source
|
||||||
|
leaves them **insensitive, not hidden** (`apply_source_panels` in `src/ui_source.cpp`).
|
||||||
|
- Session file naming for a non-UDPS kind: the pseudo-host is `<kind>-<identity>` (characters outside
|
||||||
|
`[A-Za-z0-9._-]` become `_`) on port **1** (0 is rejected by the session-file parser), the kind is
|
||||||
|
stored as `source_kind` and its settings as `source_opt_<key>`. A UDPS file keeps `<host>_<port>` and
|
||||||
|
has neither key, so a legacy session is read as UDPS and nothing existing moves.
|
||||||
|
- **The E-1608 kind has three layers, and only the top one knows the framework.** `e1608_link`
|
||||||
|
(discovery, handshake, framed TCP commands, the scan socket) and `e1608_device` (typed commands,
|
||||||
|
factory calibration, `decode_scans`) are plain POSIX C++ with no GTK or `DataSource` types; discovery is
|
||||||
|
`Scan` (one socket per interface, polled by the caller: the form polls it from a GLib timer on the main
|
||||||
|
thread, the source's lookup goes through `discover_broadcast`);
|
||||||
|
`e1608_source` is the `DataSource`; `e1608_kind` and `e1608_panels` are the UI. A change to the wire
|
||||||
|
never reaches the UI files.
|
||||||
|
- The E-1608 source runs **everything inside `poll()`** on the producer thread: posted commands, the
|
||||||
|
connect state machine (Down, Scanning, Halted), the scan read, the 500 ms status check (its not-running restart is skipped when a hardware trigger is configured), the digital
|
||||||
|
input/counter poll and the waveform tick (every `1 / gen_rate` s, 20 ms by default; one synchronous DAC write per running channel, so a slow board lowers the rate it achieves, which the Analog out panel reports). There is no second thread. A connect attempt (discovery,
|
||||||
|
handshake, calibration read, scan start) can block `poll()` for as long as the command timeout allows each
|
||||||
|
step; that delays only the command queue. Every exception is caught inside `poll()`: an escape would end
|
||||||
|
the producer thread, and `drain_commands()` runs inside the same `try` for that reason.
|
||||||
|
- Its frames are **fixed-size blocks** of about 10 ms (`round(scan_rate / 100)` scans, 1 to 4096), not
|
||||||
|
whatever one `recv` returned: `build_stamps` warns once when a frame's sample count differs from the
|
||||||
|
first one's. Digital lines and the counter are held across a block (a step trace).
|
||||||
|
- The E-1608 panels read the board's state from `E1608Readouts`, a set of atomics the producer writes
|
||||||
|
and a tick callback throttled to 100 ms reads; a panel never touches the source's internals, and writes
|
||||||
|
only through `post()`. `Acquisition::on_event` ignores the detail string and does nothing for `Error`,
|
||||||
|
so the source also writes each new error to stderr as `[e1608] ...` (once per distinct message, not per
|
||||||
|
retry); that line is where a user sees why a connection failed.
|
||||||
|
- **The flat UDP kind is three files and no thread.** `flatudp_signals` is the `signals` string codec,
|
||||||
|
the validity rules and the decoders, with no GTK and no sockets (the probe round-trips it);
|
||||||
|
`flatudp_source` is `FlatUdpSettings` plus the `DataSource`; `flatudp_kind` is the form, a signal-row
|
||||||
|
editor (a name, a type, a count and, while the count is above 1, a "Vector as" drop-down per row). The
|
||||||
|
datagram has no description, so a list that is wrong is invisible except as dropped datagrams: the size
|
||||||
|
check is the only defence, and it is why a wrong-size datagram is counted (`stale`) and reported once per
|
||||||
|
size.
|
||||||
|
- A vector row is shown one of two ways, chosen per row (`Row::wave`, the `w` suffix in the text). As
|
||||||
|
**channels** it is `count` scalar channels of one sample per cycle, so the list batches like an
|
||||||
|
all-scalar one. As a **waveform** it is one channel with `rows = count` and
|
||||||
|
`sampling_rate = rate * count`, one datagram per `Frame` (`num_samples = 1`). The two cannot share a
|
||||||
|
list because the model spans a frame by its largest element block and `Frame::value` is 0 past a scalar's
|
||||||
|
one value: a scalar beside a vector block would be zero-padded, so `problem()` demands that a list with a
|
||||||
|
waveform row be waveform rows of one count, and the form shows that message in the subtitle. The channel
|
||||||
|
total is capped at 256.
|
||||||
|
- Its frames are **fixed-size blocks** like the E-1608's for the same reason (`build_stamps` warns when the
|
||||||
|
sample count varies): `round(rate / 100)` datagrams for a list with no waveform row, a partial block
|
||||||
|
flushed after 100 ms. `rate` is required because `sampling_rate = 0` makes the model size the ring and
|
||||||
|
the ingest decimation for 1 kHz; there is no time-base switch, since `acquisition.cpp` has no stamping
|
||||||
|
path that ignores the declared cadence.
|
||||||
|
- `Disconnected` after 2 s without a *valid* datagram (wrong-size ones do not refresh the clock); it resets
|
||||||
|
`Acquisition`'s per-session state, so the next datagram re-anchors rather than reading as a gap, and the
|
||||||
|
history survives (`CONFIG` is sent once, from the first `poll()`).
|
||||||
|
- The form's row editor removes a row from inside its own button's `clicked` emission (GTK keeps the emitter
|
||||||
|
alive to the end of the emission, and the handler copies the form and the row out of its context first);
|
||||||
|
every handler tests `Form::alive`, which the groups' `destroy` clears, because the chooser may have
|
||||||
|
destroyed the sibling widgets the handlers reach by raw pointer.
|
||||||
|
|
||||||
|
### The window's chrome: plot, slide-over, badge bar, dock
|
||||||
|
|
||||||
|
The main window is a plot with three things around it, each a unit of `src/ui_*.cpp` and none of them
|
||||||
|
touching the data path.
|
||||||
|
|
||||||
|
- **`SlideOver`** (`src/ui_slideover.cpp`) is one `GtkRevealer` holding a `GtkStack`, added as the second
|
||||||
|
child placed *over* the plot (after the layout editor's buttons) in `GtkOverlay#plot_overlay`. A page is
|
||||||
|
a `slideover_add_page(app, id, title, widget)`: the widget is wrapped in a `GtkScrolledWindow` of its
|
||||||
|
own and registered under its id (`trigger`, `source-<id>`, `signals` (the signal list) and
|
||||||
|
`signal:<name>` (one signal's page, `src/ui_signal_page.cpp`, created on demand by `signal_page_ensure()`
|
||||||
|
and pruned by `signal_pages_sync()`, which `rebuild_signal_panels()` ends with), `cursors` (the cursors
|
||||||
|
panel, unchanged) and `measure` (the measurements panel, unchanged; `m` toggles it); `events` (the full events list, `events-pane.ui`'s `events_box`: the Live, Compare and Accumulate
|
||||||
|
controls and the multi-select list with its thumbnails; open it only through `open_events_page()`, which
|
||||||
|
refreshes the list first, because the slide-over has no on-open hook). They are registered whether or not
|
||||||
|
they are open (a closed page's widgets exist, unmapped); `cursors` and `measure` are insensitive until the
|
||||||
|
stream has signals, by the same `update_signal_availability()` call that sets the dock's sensitivity, and
|
||||||
|
`events` follows `sync_events_availability()` (below). One page is open at a time and `App::slideover.page`
|
||||||
|
is its id, empty when closed. The open page is **not** a session value. Esc, *Close*, a second click on the
|
||||||
|
badge and a click on the plot close it; the click-away is a capture-phase observer (see
|
||||||
|
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). With two or more `source-*` pages, an
|
||||||
|
`AdwToggleGroup` under the header is the segmented switch between them.
|
||||||
|
- **`BadgeBar`** (`src/ui_badges.cpp`) is the fixed-height row between the plot and the status line
|
||||||
|
(`main-window.ui` `badge_bar`, two slots: signals, left, a scroller holding one badge per enabled signal
|
||||||
|
and the `+`, or a single "N signals" badge past six; system, right, holding the **Trigger** and
|
||||||
|
**Source** badges). `sync_signal_badges()` rebuilds the signal badges (from `rebuild_signal_panels()`);
|
||||||
|
their order is `App::badge_order`, in memory only. A badge is a flat button whose `page` is the slide-over page it
|
||||||
|
toggles; the Source badge's page is the first `source-*` page, and a kind with none (UDPS, Flat UDP, a
|
||||||
|
file) leaves it **hidden** (the user asked for hide here, not the usual disable): `sync_badge_bar()` sets its visibility,
|
||||||
|
pick and focus from `slideover.source_pages` being non-empty every tick, so anything that indexes the badge
|
||||||
|
(the tour's `badges` step targets the whole bar, `[badges]`) must tolerate it being absent.
|
||||||
|
- **The dock** is the `AdwOverlaySplitView` sidebar (`side_box` > `dock_scroller` > `dock_page`), **280 px**
|
||||||
|
and fixed. It holds two things, the **Readouts** box and, under a separator, the **Events** block (below). The Readouts box (`src/ui_dock.cpp`, `build_readouts_dock()`): a
|
||||||
|
**Cursors** block (heading, an **Edit** button that opens the `cursors` page, a `GtkGrid` of a header row
|
||||||
|
`A B delta` and one line per page line) and a **Measurements** block (heading, **Edit** opening the
|
||||||
|
`measure` page, one line per measurement: chip, type, value; a click on a line opens that measurement's
|
||||||
|
dialog; no measurements shows one dim "no measurements" line). It is read-only and **owns no formatting**:
|
||||||
|
`update_cursors_pane()` and `update_measures()` hand the string they wrote to the page's label to
|
||||||
|
`dock_cursor_cell()` / `dock_measure_value()` as well, and `rebuild_cursors_pane()` /
|
||||||
|
`rebuild_measures_pane()` end by rebuilding the dock's lines to match (`dock_rebuild_cursors()`,
|
||||||
|
`dock_rebuild_measures()`). It holds no tab switcher, no revealer and none of the full panels, nor a source
|
||||||
|
kind's panels (those are slide-over pages). The window's measurement editor is a dialog
|
||||||
|
(`open_measure_dialog()`), not part of the `measure` page.
|
||||||
|
|
||||||
|
**The readouts strip** (`readouts_strip` in `main-window.ui`, between `plot_overlay` and `badge_bar` in
|
||||||
|
`left_column`; filled by `ui_dock.cpp`) carries the same readouts when the dock is not beside the plot: a flat
|
||||||
|
**Cursors** button and one chip (`who` + delta) per cursor line, then a **Measures** button and one chip per
|
||||||
|
measurement. `sync_readouts_strip()` shows it exactly when `(!adw_overlay_split_view_get_show_sidebar() ||
|
||||||
|
adw_overlay_split_view_get_collapsed())` **and** (the cursors toggle is active **or** `app.measures` is
|
||||||
|
non-empty) -- the Dock button put the dock away, or the window is collapsed and the dock is an overlay on the
|
||||||
|
plot (the strip stays on screen even while that overlay is open), and there is something to read. Inside, the
|
||||||
|
Cursors group (`strip_cursor_group`) is shown with the cursors on, the Measures group (`strip_measure_group`)
|
||||||
|
with measurements, the separator (`strip_sep`) with both; with neither the strip is hidden. Besides the tick,
|
||||||
|
the cursors toggle handler and `rebuild_measures_pane()` call it, so a toggle or an add/delete shows at once. The tick
|
||||||
|
calls it right after re-asserting `sidebar_wanted` and *before* `set_collapsed()` (so the split view's own
|
||||||
|
collapse-time `show-sidebar` write is never read); that means the `collapsed` it reads is the previous
|
||||||
|
frame's, and the strip follows a collapse or a widening one tick late, never through a wrong state. Its labels (`strip_cursor_who`, `strip_cursor_deltas`, `strip_measure_labels`) are rebuilt by
|
||||||
|
`dock_rebuild_cursors()` / `dock_rebuild_measures()` and written by `dock_cursor_cell()` /
|
||||||
|
`dock_measure_value()` from the same argument as the dock's, so there is no second formatter;
|
||||||
|
`dock_set_cursors_sensitive()` is the one writer of the cursor readouts' sensitivity (the dock's grid and
|
||||||
|
the strip's chip box).
|
||||||
|
|
||||||
|
The **Events** block (`build_events_dock()`, also `ui_dock.cpp`) is a heading with a **Manage** button
|
||||||
|
(`open_events_page()`) over a short list (`dock_events_list`, 112 px, scrolled, no thumbnails): one compact
|
||||||
|
row per `EventRecord`, newest first, capped at `kDockEventsMax` (24) with a dim "... N more, see Manage"
|
||||||
|
row after them. A row's cells are a one-character marker (filled = the event on screen; hollow = a member
|
||||||
|
of the group on screen -- dock only, the page's rows do not mark members; blank otherwise), the title
|
||||||
|
(`event_row_title`, ellipsized, full text in the tooltip) and `#<seq>`; **one click replays** it
|
||||||
|
(`replay_event()`, the body of the page's double-click handler). Both views are rebuilt from one
|
||||||
|
`EventStore::list()` by `refresh_events_pane()`, but on different cadences: **the dock follows every
|
||||||
|
latch** (`record_event()` refreshes it unconditionally), while the page's list is refreshed per latch only
|
||||||
|
while the page is open and by `open_events_page()` otherwise. `mark_events_on_screen()` updates titles and
|
||||||
|
markers in place and never rebuilds either list. The block, and the page, are insensitive (never hidden)
|
||||||
|
unless `sync_events_availability()` says `event_recording || replaying || events_cached` -- a different
|
||||||
|
rule from the spec's (see [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)).
|
||||||
|
There is no events pane, divider or header toggle any more: the page is reached from Manage (or by a
|
||||||
|
caller of `open_events_page()`), and the plot is never squeezed by events.
|
||||||
|
|
||||||
|
**When each is built and when it is read.** `build_slideover()` runs in `on_activate` right after the tour
|
||||||
|
overlay is built (the tour's overlay wraps the whole window, so its dim rectangles cover the slide-over
|
||||||
|
too); the trigger form is registered as
|
||||||
|
a page by `build_trigger_panel`; `build_source_panels()` registers the source pages; and
|
||||||
|
`build_badge_bar()` runs *after* all of them, because the Source badge needs to know the first page.
|
||||||
|
Per-tick state (the Trigger word, the Source name, the open marks) is written by
|
||||||
|
`sync_badge_bar()`, called from `tick()` and from `slideover_open`/`slideover_close`; it writes a label
|
||||||
|
only when its text changed. Nothing in the badge bar or the slide-over may be read from the producer
|
||||||
|
thread.
|
||||||
|
|
||||||
|
### Data path
|
||||||
|
|
||||||
|
`ScopeModel` holds **one `BufferStore`** (`live()`): one shared time-base ring plus one
|
||||||
|
value ring per channel, sized by `plan_partition()`: `--buffer-samples` ticks (rounded down to a power of two) per ring, halved
|
||||||
|
until the real bytes fit `--memory-limit-mb` (`Partition::memory_limited` records a clip), and the shared
|
||||||
|
clock decimated until `--max-window` seconds fit. 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` **plots** (rows are added
|
||||||
|
and removed one at a time by the layout editor, and a row can be split into up to
|
||||||
|
`kMaxCellsPerRow` cells side by side), each plot a
|
||||||
|
borrowed `plot::Plot*` with `kSlotsPerRow` fixed trace slots; `row(i)` indexes the plots row-major,
|
||||||
|
`figure_row_count()` counts the rows. The figure owns
|
||||||
|
everything the plots have to agree on: the x window (one x link group every plot 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 y range of each figure row's cells (`set_share_row_y`: a split
|
||||||
|
row has one y axis, one ruler pair and one fit), 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,390 @@
|
|||||||
|
# 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
|
||||||
|
./build/udpscope --source udps --source-opt server=127.0.0.1 --source-opt port=8000 # same as --server/--port
|
||||||
|
```
|
||||||
|
|
||||||
|
`--help` lists the registered source kinds.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
The interface files can be checked without building anything or opening a window:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build --target test-ui # or: make -C build test-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
That round-trips every `.ui` through `blueprint-compiler` — `decompile` turns it into blueprint, and
|
||||||
|
compiling *that* type-checks every property and child type against the GIR — printing one line per file.
|
||||||
|
It exists because the alternative is what happens otherwise: a property a class does not have is a
|
||||||
|
`Gtk-ERROR` when the window is built, and `Gtk-ERROR` **aborts the process**, so a typo in a `.ui` takes
|
||||||
|
the application down rather than failing to open a dialog. `preprocess="xml-stripblanks"` catches
|
||||||
|
malformed XML and nothing else, and the probes catch the rest only for the windows they open.
|
||||||
|
|
||||||
|
The XML stays the source of truth and stays hand-written: this is a check, not a compiler, and nothing
|
||||||
|
here is needed to *build* the application. `blueprint-compiler` and `python3` are found at configure
|
||||||
|
time; without them the target is absent rather than silently passing. `tools/check_ui.py --lint` also
|
||||||
|
prints the linter's advice, which is advice and deliberately not a gate.
|
||||||
|
|
||||||
|
The manual has its own check, the same shape:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build --target test-help
|
||||||
|
```
|
||||||
|
|
||||||
|
That runs `yelp-check`'s four offline subcommands over `help/C/<id>/` (links, media, orphans, ids)
|
||||||
|
and renders the whole set with `yelp-build html` into a temp directory, so the manual is checked the
|
||||||
|
way the `.ui` files are. **`yelp-check validate` is deliberately not used**: it fetches the Mallard
|
||||||
|
RelaxNG schema over the network, which is absent on a build box, so it would fail for that reason and
|
||||||
|
not for a fault in the pages. `yelp-check`/`yelp-build` are found at configure time; without them the
|
||||||
|
target is absent rather than silently passing.
|
||||||
|
|
||||||
|
The manual is a Mallard set under `help/C/<id>/`, installed by an `install()` rule to
|
||||||
|
`<datadir>/help/C/<id>/` — where the freedesktop Help System Specification's `help:` URI resolves
|
||||||
|
`help:<id>` — so it is *read from a datadir by another process* even though the binary itself has no
|
||||||
|
install step. `F1` opens it in Yelp through that URI. The document identifier is the desktop file's
|
||||||
|
basename (`data/org.udpscope.app.desktop.in` → `org.udpscope.app`), so it is the `.desktop` name, the
|
||||||
|
GApplication id and the install directory at once. The figures are regenerated by
|
||||||
|
`tools/make-help-shots.sh`, which drives the app headlessly against the mock and draws the annotation
|
||||||
|
boxes with ImageMagick; Mallard is translatable with `itstool`, but only the `C` set is authored.
|
||||||
|
|
||||||
|
Because the manual is the only thing udpscope installs, it can be installed *alone* — where `help:`
|
||||||
|
finds it, and without the `cmake --install` of a whole project — by either of two opt-in targets.
|
||||||
|
Neither is built by default and `cmake --build build` never touches either:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build --target install-help-user # <data-home>/help/C/<id>/, no root
|
||||||
|
cmake --build build --target install-help # <CMAKE_INSTALL_PREFIX>/share/help/C/<id>/
|
||||||
|
```
|
||||||
|
|
||||||
|
`install-help-user` writes to `$XDG_DATA_HOME` when it is set and to `$HOME/.local/share` when it is
|
||||||
|
not — the XDG Base Directory Specification's own default for the variable, and the fallback
|
||||||
|
`help_installed_path()` uses — so it needs no root and lands the set exactly
|
||||||
|
where `F1` and the alert look for it. `install-help` goes through the install machinery into the
|
||||||
|
configured `CMAKE_INSTALL_PREFIX`, so a packager or a system-wide user points it at `/usr` with
|
||||||
|
`-DCMAKE_INSTALL_PREFIX` at configure time. Neither target can install system-wide by itself: a build
|
||||||
|
target has no more privilege than the shell that ran it, so `install-help` writes to the configured
|
||||||
|
prefix and a system-wide caller wraps it in `sudo`. Both print where they wrote. Both land the same
|
||||||
|
tree the `install()` rule does — the user-local copy applies that rule's file filter, and the
|
||||||
|
prefix-wide target *is* that rule, reached through the `Help` component
|
||||||
|
(`cmake --install --component Help`) — and the ordinary `cmake --install` still installs every
|
||||||
|
component, so its behaviour is unchanged.
|
||||||
|
|
||||||
|
Options: `--source NAME` and the repeatable `--source-opt k=v` (the source kind and its settings;
|
||||||
|
`--server`, `--port`, `--multicast` and `--interface` populate the UDPS entries of the same options),
|
||||||
|
`--server`, `--port`, `--multicast`, `--interface`, `--buffer-samples` (samples
|
||||||
|
per acquired signal, default 10M, k/M/G suffixes, rounded down to a power of two),
|
||||||
|
`--memory-limit-mb` (hard ceiling on the ring bytes, default 8192; also a Preferences row),
|
||||||
|
`--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
||||||
|
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards),
|
||||||
|
`--capture-packets N` (the raw-datagram ring of README's "Packet capture"; 1..100000,
|
||||||
|
the value is required, and the popover button uses 1000 when the flag was absent).
|
||||||
|
The flag is **not** persisted in the session file: persistence of the enable state
|
||||||
|
belongs with `todo.md` item 3, alongside the relative-time toggle.
|
||||||
|
`--renderer cairo|gl|vulkan` exports `GSK_RENDERER` **at the top of `main()`** —
|
||||||
|
before `choose_source()`'s `adw_init()` and `run_ui()`'s, because GSK reads the variable when
|
||||||
|
it creates its renderer and setting it later is a silent no-op. `cairo` is the software preset
|
||||||
|
and also exports `LIBGL_ALWAYS_SOFTWARE=1`: the plot is a `GtkGLArea`, so a display with no
|
||||||
|
usable GPU fails twice, and this is the pair `tools/make-help-shots.sh` already sets. Nothing
|
||||||
|
is exported when the option is absent, so GTK's own choice stands.
|
||||||
|
|
||||||
|
`--source e1608` selects the MCC E-1608 Ethernet DAQ; its settings are `--source-opt` keys, all optional
|
||||||
|
(`address` or `mac`+`subnet`, `port` 54211, `code` 0, `timeout_ms` 2000, `scan_rate` 1000 per channel,
|
||||||
|
`ext_clock`, `trigger` `none|rising|falling|high|low`, `auto_restart` 1, `calibrate` 1, `poll_rate` 10,
|
||||||
|
`gen_rate` 50 (Hz, 1 to 200: the waveform generator's DAC update rate), `mode` `se|diff`, `ai0`..`ai7` `BIP10V|BIP5V|BIP2V|BIP1V` (an absent key = off, **and any `aiN` key at all
|
||||||
|
drops the default**: only a run with no `aiN` key gets `ai0=BIP10V`, so `ai1=BIP5V` alone is channel 1
|
||||||
|
alone, and a value that is not a range name is a channel that is off), `dir0`..`dir7` `i|o`,
|
||||||
|
`plot0`..`plot7`, `plot_counter`, `do_init`, `ao0_init`, `ao1_init`). Either `address` or `mac` is
|
||||||
|
required; a MAC with no `address` is looked up by a broadcast sent on every interface (`Scan`), or by a sweep of
|
||||||
|
`subnet` (`a.b.c`).
|
||||||
|
The enabled channels times `scan_rate` must not exceed 250 kS/s. `tools/mock_e1608.py` is a protocol
|
||||||
|
mock of the board for development (UDP discovery and handshake, TCP commands, a scan stream with a sine
|
||||||
|
per channel, calibration memory, `--port`, `--mac`, `--log FILE`, `--drop-after S` and `--overrun-after S`
|
||||||
|
switches, `--code N`); the mock stands in for the board (a real board has been run once, with one channel; see
|
||||||
|
the README), and the mock's header lists the items whose layout is unverified. Errors are written to stderr as `[e1608] ...`, once per distinct message.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_e1608.py --port 54711 &
|
||||||
|
./build/udpscope --source e1608 --source-opt address=127.0.0.1 --source-opt port=54711 \
|
||||||
|
--source-opt ai0=BIP10V --source-opt ai1=BIP5V --source-opt plot0=1
|
||||||
|
```
|
||||||
|
|
||||||
|
`--source flatudp` receives the MARTe2 `UDPSender` datasource (one datagram per cycle: the signals
|
||||||
|
concatenated in declaration order, no header, no padding). The datagram carries **no description**, so the
|
||||||
|
settings are the whole contract: `port` 44488, `bind`, `multicast` and `iface` (a group to join and the local
|
||||||
|
interface for it), `rate` (the sender's cycle rate in Hz; **required**, it sizes the ring and the ingest
|
||||||
|
decimation and spaces the samples within a frame), `endian` `little|big`, and `signals`
|
||||||
|
`name:type[count][w],...` with types `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64`, e.g.
|
||||||
|
`Trigger:uint8,Ia:float32,Wave:float32[16]`. A count above 1 makes a vector signal, shown one of two ways: by
|
||||||
|
default as `count` **channels** `Wave[0]`..`Wave[15]` of one sample per cycle each (batched like any scalar
|
||||||
|
list); with a trailing `w` (`Wave:float32[16]w`) as ONE **waveform** channel receiving `count` consecutive
|
||||||
|
samples per cycle at `rate * count`, as a UDPStreamer block signal. A waveform row needs a count above 1, a
|
||||||
|
list with one holds only waveform rows of one count (no zero-padding of scalars), and a list may make at most
|
||||||
|
256 channels (a scalar 1, a `channels` row `count`, a waveform 1). A datagram of any other size than
|
||||||
|
the list implies is dropped and counted as stale; each distinct wrong size is reported once on stderr as
|
||||||
|
`[flatudp] ...`. Loss is never reported: the stream has no counter. `tools/mock_flatudp.py` is a sender for
|
||||||
|
a signal spec (`--rate`, `--signals`, `--big-endian`, `--gap S,D`, `--bad-every S`, `--multicast G --mcast-if A`,
|
||||||
|
`--log FILE`).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_flatudp.py --port 44588 --rate 1000 --signals 'Trigger:uint8,Ia:float32,Wave:int16[4]' &
|
||||||
|
./build/udpscope --source flatudp --source-opt port=44588 --source-opt rate=1000 \
|
||||||
|
--source-opt 'signals=Trigger:uint8,Ia:float32,Wave:int16[4]'
|
||||||
|
```
|
||||||
|
|
||||||
|
(Quote the `signals` value: zsh globs the `[4]`. The mock takes the same text with or without the trailing `w`;
|
||||||
|
the datagram is the same, `w` only says how the app shows it.)
|
||||||
|
|
||||||
|
`--source tcps` receives a MARTe2 `TCPStreamer` (standalone) or `TCPStreamService` (named stream) over
|
||||||
|
TCP: `server` (default 127.0.0.1), `port` 44500, and `stream` (optional; a `SUBSCRIBE` frame is sent only when it is
|
||||||
|
set). `tools/mock_tcpstreamer.py` serves one client with the `mock_udpstreamer.py` signals (`--port`, `--stream`
|
||||||
|
to require a `SUBSCRIBE`, `--rate`, `--batch`, `--freq`, `--close-after S`, `--drop-every N`).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_tcpstreamer.py --port 44500 --stream fast &
|
||||||
|
./build/udpscope --source tcps --source-opt port=44500 --source-opt stream=fast
|
||||||
|
```
|
||||||
|
|
||||||
|
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
|
||||||
|
|
||||||
|
- `--square-harmonics N` shapes `square`: `N` odd harmonics of the ideal step's Fourier
|
||||||
|
series, so each edge overshoots by the Gibbs constant (about 9 % of the 2 V swing at the
|
||||||
|
default 30, i.e. `y = ±1.179`) and then rings — a band-limited edge, which is what a
|
||||||
|
crossing count or a pulse-width test has to survive. The series is deterministic and the
|
||||||
|
odd harmonics keep the ideal wave's edge instants and its polarity, so the level-0 trigger,
|
||||||
|
the search and the glitch's inverted pulse still read where they did on the step; the
|
||||||
|
*extremes* are what move. `0` (or negative) is the ideal ±1 step, which is what a probe
|
||||||
|
asserting an exact extreme or an exact width asks for — the noise probe's group-delay leg is
|
||||||
|
the one that does, because its `want` is the analytic `tau*ln2` of a step
|
||||||
|
(see [verification.md](verification.md)). It costs the producer about 0.5 ms per
|
||||||
|
2000-sample packet on top of the ideal branch, so at the fast mock's 1.3 MS/s the mock
|
||||||
|
delivers at roughly nine tenths of its declared pace — a reading that scales with the
|
||||||
|
retained span reads that much lower, and nothing else does.
|
||||||
|
- `--glitch-at S` / `--glitch-width W` invert the `square` signal for `W` seconds at the
|
||||||
|
start of every square period from stream second `S` onwards — a runt pulse of a known
|
||||||
|
width, which is what a `<` pulse-width trigger and a glitch search are checked against.
|
||||||
|
- `--quiet-at S` freezes `sine` at its held value from stream second `S` onwards, so it
|
||||||
|
stops crossing zero: the dropout trigger's input.
|
||||||
|
- `--spike-every S` / `--spike-amp A` / `--spike-width N` inject an alternating-sign
|
||||||
|
impulse train onto `sine` only — the other four signals (`time`, `square`, `ramp`,
|
||||||
|
`noise`) are untouched, which is what lets a probe compare a spiked channel against a
|
||||||
|
clean one in the same run. Every `S` seconds a spike of `N` samples at amplitude `A` is
|
||||||
|
added to `sine`; the sign alternates per period because a one-signed train is a DC offset
|
||||||
|
a median would partly track rather than reject. `N` is in **samples**, not seconds, so its
|
||||||
|
duration follows `--rate`. `0` (the default for both `S` and `A`) injects nothing, so
|
||||||
|
every existing invocation is unchanged. **`--batch` must stay at or below 1000** with or
|
||||||
|
without these options: 2000 samples × 5 signals × `f64` is 80 kB and exceeds UDP's
|
||||||
|
~65507-byte payload limit, and the mock dies on its first `sendto` with
|
||||||
|
`OSError: [Errno 90] Message too long` while the run still exits 0.
|
||||||
|
|
||||||
|
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. `deps/udpsclient` gained a TCP
|
||||||
|
transport mode (item 6 below) and one
|
||||||
|
additive entry point for the packet capture — `udps_client_set_datagram_cb()`, firing
|
||||||
|
inside `udps_client_poll()` immediately after the UDP `recv()` returns, before the
|
||||||
|
magic check or reassembly — plus the public `UDPS_MAX_DATAGRAM_BYTES` the capture
|
||||||
|
sizes its slots from. A separate setter, not a fourth parameter on
|
||||||
|
`udps_client_set_callbacks()`: that signature has call sites, and a capture hook is no
|
||||||
|
reason to change them.
|
||||||
|
|
||||||
|
**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/udpsclient` is a vendored C99 source pair and its diff from the upstream source
|
||||||
|
is intentional. Six things were changed:
|
||||||
|
|
||||||
|
1. **A reorder ring of reassembled payloads.** When a packet arrives with a higher counter
|
||||||
|
than expected, the raw datagram is held in a fixed-size ring rather than decoded immediately.
|
||||||
|
A deadline (`now + reorder_window_s`, default 0.020 s) is set for the gap; the deadline is
|
||||||
|
served by a third `clamp_deadline()` call on the `select()` budget
|
||||||
|
(`udps_client.c:1145-1152`) so a quiet stream does not wait for the next arrival.
|
||||||
|
2. **Late delivery replacing the outright discard.** A packet that arrives past the window — or
|
||||||
|
when the ring is drained by the deadline — is delivered to the callback flagged `late` rather
|
||||||
|
than discarded with `stats.stale_packets++; return 0`. That flag is what makes backfill
|
||||||
|
possible at all; without it the samples are gone before udpscope can act.
|
||||||
|
3. **The third `clamp_deadline()`.** The existing pair handles the keepalive and the silence
|
||||||
|
timeout; the reorder deadline is a third call to the same helper, which already understood
|
||||||
|
the pattern.
|
||||||
|
4. **`reorder_window_s = 0` as the switch.** `udps_client_config_t::reorder_window_s`,
|
||||||
|
defaulted to `UDPS_REORDER_WINDOW_S_DEFAULT` (0.020) in `udps_client_config_init()`,
|
||||||
|
is the only knob. Setting it to zero disables resequencing and restores the previous
|
||||||
|
discard-on-late behaviour exactly — so a reported regression is bisectable without reverting
|
||||||
|
the source. **There is no command-line flag for the reorder window.** It is not surfaced on
|
||||||
|
udpscope's command line; do not document a `--reorder-window-ms`.
|
||||||
|
5. **The full ring never drops the arriving packet, and says so in a counter.** A packet that
|
||||||
|
needs a slot and finds none commits the current gap (which frees at least one, since
|
||||||
|
`commit_gap()` always delivers) and the ordering decision is taken once more against the
|
||||||
|
sequence that commit left behind — a bounded retry, because the commit can advance
|
||||||
|
`next_counter` past the arriving counter and turn an ahead-of-sequence packet into a behind-
|
||||||
|
of-sequence one. Past the retry it is delivered *in order*, with the counters it skipped as
|
||||||
|
its `lost`, and never flagged `late`: a packet ahead of the sequence has no reserved hole and
|
||||||
|
`Acquisition::backfill_from()` silently ignores a late packet it cannot place, so flagging it
|
||||||
|
would be the same silent loss by another route. `stats.reorder_overflows` counts the event,
|
||||||
|
so an undersized ring is a reading rather than a mystery; it is surfaced in the exit dump and
|
||||||
|
the `[reseq-probe]` line as `overflows=`.
|
||||||
|
6. **A TCP transport mode.** `udps_client_config_t::use_tcp` / `stream_name` select a TCP
|
||||||
|
connection to a MARTe2 `TCPStreamer` or `TCPStreamService`: `connect_tcp()` (a 3 s connect
|
||||||
|
bound through `SO_SNDTIMEO`, a 1 s receive bound, an optional `SUBSCRIBE` frame,
|
||||||
|
`UDPS_PKT_SUBSCRIBE = 5`, sent only when `stream_name` is set), the existing
|
||||||
|
`read_tcp_frame()` for the frames, the reorder window forced to 0 (TCP is ordered, so a
|
||||||
|
counter gap is a server-side drop and is reported at once), multicast forced off, no
|
||||||
|
DISCONNECT frame on close (a write to a closed peer would raise SIGPIPE), no keepalive ACK
|
||||||
|
and no datagram tap. The receive buffer is `max_packet_bytes + header` (1 MiB by default,
|
||||||
|
matching the server's frame ceiling) in TCP mode, since a TCP frame is never fragmented and
|
||||||
|
a MARTe cycle of large arrays is one frame; it is `UDPS_MAX_DATAGRAM_BYTES` only for UDP. A
|
||||||
|
stream name over 255 bytes is refused at create.
|
||||||
|
|
||||||
|
`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.
|
||||||
|
|
||||||
|
A figure row can hold **several plots side by side**, which the host's horizontal split needs and
|
||||||
|
the library had no concept of: `Figure(std::span<const u32> cells_per_row)` builds one, with
|
||||||
|
`plot_count()`, `cells_in_row()`, `first_plot_of_row()` and `row_of_plot()` to move between rows and the
|
||||||
|
flattened plot index, `set_cell_weights()` and `set_col_gap()` to size the cells, and `FigureLayout::cells`
|
||||||
|
(one rect per plot, flattened) and `FigureLayout::col_gap` to read the result back. `Figure(u32 rows)`
|
||||||
|
still means one cell per row, so every row-only caller and test is unchanged, and `row_count()` stays the
|
||||||
|
number of *figure rows* (the host's `RowSet::row_count()` is the plot count, `figure_row_count()` the
|
||||||
|
rows). `plot(i)`, `link_x()` and the crosshair's hovered index all take the **flattened** plot index.
|
||||||
|
Cell widths use the height scheme: a floor of **64 px logical** per cell, the weights share the rest,
|
||||||
|
the last cell closes the row exactly, and a figure too narrow for the floors shrinks the cells
|
||||||
|
proportionally and drops the column gap. The x labels and title follow the **column structure**: a row
|
||||||
|
hides its x strip (`XStrip::None`) only when the row directly below has the *same columns* (same cell
|
||||||
|
count, same normalised cell weights within 1e-4, empty weights = equal), so `[1][2]/[3][4]` has an axis
|
||||||
|
under 3 and 4 only while `[1][2]/[3]` has one under 1, 2 and 3; the rule is structural, transitive down a
|
||||||
|
chain and decided before the measurement pass (`same_columns()` in `figure.cpp`); `set_x_labels_only_on_bottom(false)`
|
||||||
|
labels every row. The left y gutter stays one maximum over the whole figure.
|
||||||
|
|
||||||
|
The cells of a figure row can **share one y range**, symmetric to the x link
|
||||||
|
(`docs/superpowers/specs/2026-11-03-split-shared-y-design.md`):
|
||||||
|
|
||||||
|
- `Figure::set_share_row_y(bool)`, default off so every existing figure and `tests/test_figure.cpp`
|
||||||
|
read as before; the host turns it on in `RowSet::rebuild_all()` before the cells are populated. When
|
||||||
|
on, the cells of each figure row form an implicit y link group, rebuilt with the cells.
|
||||||
|
- Link semantics: `layout()` runs a `sync_y_links()` that adopts a member's changed `y_range` as the
|
||||||
|
group's and pushes it to the others, so a wheel, pan, box zoom or axis drag (all `set_y_range`) needs
|
||||||
|
no controller change; a manual `set_y_range` on a member clears the group's auto flag. A member's own
|
||||||
|
autoscale is suppressed and the group owns the flag: it fits to the union of its members' visible
|
||||||
|
traces (cells with no trace are ignored; a log axis takes the union in axis space), and `fit_all` and
|
||||||
|
a double-click fit the group, not one cell. The group only follows on a laid-out frame, so a caller
|
||||||
|
that sets a range or a fit and reads it back needs one `layout()` in between.
|
||||||
|
- `YStrip {Both, None}` and `Plot::set_y_strip_mode`, mirroring `XStrip`: `None` forces the y label
|
||||||
|
width, title width and reserved floor to 0. `Figure::layout` sets `None` on every cell but the first
|
||||||
|
of its row, structurally from the column index and never from content, and skips `None` cells in the
|
||||||
|
common-gutter maximum. A y axis drag can therefore start only in the leftmost cell.
|
||||||
|
- **Cell placement is by plot width**: the row's y gutter comes off the row width first, then the cells
|
||||||
|
share the rest by the existing `floor + extra * w / total` rule, the floors applying to plot widths.
|
||||||
|
Equal weights give equal plot areas; the first cell's rect is gutter plus its plot width and the rects
|
||||||
|
still tile the row exactly.
|
||||||
|
- **The right gutter belongs to the last cell.** In a shared-y split row only the last cell gets
|
||||||
|
`set_right_gutter()`; it is taken off the row beside the y gutter (capped so both fit in half the row
|
||||||
|
and never beyond the figure's right gutter) and the last cell's rect grows right by it. The gap between
|
||||||
|
two plot frames is then exactly `col_gap` rather than `col_gap` plus a gutter; unsplit rows and rows
|
||||||
|
with sharing off lay out as before (`figure_split_row_gap_*`, `figure_row_gutter_without_sharing_is_the_old_layout`).
|
||||||
|
|
||||||
|
In the GTK4 and GLFW adapters the pointer is routed by **cell rect** (`row_in(rows, x, y)`: the
|
||||||
|
containing cell, else the nearest by squared distance), one `input::Controller` per plot. The tests are
|
||||||
|
the `figure_cell*` cases in `tests/test_figure.cpp` and `glfw_host_routes_by_cell`.
|
||||||
|
|
||||||
|
`plot::data::RingBuffer` carries two writes a plain SPSC ring has no use for and this app
|
||||||
|
does: `amend(absolute, v)` rewrites a retained slot when a late packet's data finally
|
||||||
|
arrives, and `clear_to(absolute)` empties a ring and **resumes its count at a given
|
||||||
|
origin**. The second exists because a math channel's ring is recomputed from its inputs'
|
||||||
|
*retained* window: `clear()` would restart it at 0 and leave every absolute index —
|
||||||
|
`base()`, `produced()`, `copy_from()` — short by that window's base, which is the
|
||||||
|
desynchronisation `live_window_locked()` then reads as an empty window (see
|
||||||
|
[invariants-math-channels.md](invariants-math-channels.md)). Its guards are
|
||||||
|
`ring_clear_to_resumes_the_absolute_count` — a derived ring refilled from a source's retained
|
||||||
|
window must come back with the source's own `produced()`/`base()` — and
|
||||||
|
`ring_clear_to_zero_is_clear`.
|
||||||
|
|
||||||
|
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 box-zoom selection was the one gesture with no
|
||||||
|
feedback at all: the controller tracked the rect and nothing drew it. The drawing went upstream with
|
||||||
|
the rest — `Plot::set_selection_rect()` (the plot carries a rect the host hands it and learns nothing
|
||||||
|
about pointers), `Controller::selection_preview()` (the mask decides which axis is *not* zoomed, and
|
||||||
|
the preview is widened along it, because the swept sliver of a horizontal Ctrl drag is the gesture and
|
||||||
|
the band across the plot is what the release will apply) and `Renderer::draw_selection_frame()` — and
|
||||||
|
it carries three tests: `a_ctrl_box_zoom_previews_the_band_it_will_zoom`,
|
||||||
|
`the_selection_frame_is_carried_and_taken_off` and `the_box_zoom_frame_lands_exactly_where_it_says`,
|
||||||
|
the last of which reads the headless framebuffer back and compares the same pixels with and without
|
||||||
|
the frame. 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,372 @@
|
|||||||
|
# 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.
|
||||||
|
- **A rebuild that would plan the *same* storage clears the store instead of allocating one.**
|
||||||
|
`rebuild_locked()` compares the new `Partition` against the current one with
|
||||||
|
`Partition::same_storage_as()` — every field that decides an allocation: `time_capacity`,
|
||||||
|
`time_stride`, `abs_time`, `base_dt` and the per-channel `stride`/`value_capacity` vectors — and
|
||||||
|
on a match calls `BufferStore::reset()` rather than constructing a new one. `base_dt` is in the
|
||||||
|
comparison on purpose: it is not a size, but it is what the slots *mean*, and keeping samples
|
||||||
|
under a changed `base_dt` would re-date the whole ring.
|
||||||
|
**Construction is the cost.** A `RingBuffer<T>` holds a `std::vector<std::atomic<T>>`, which is
|
||||||
|
value-initialised, so making a store touches every byte of the planned rings — about a second per
|
||||||
|
gigabyte, mostly first-touch page faults — while `reset()` is one relaxed store per ring plus an
|
||||||
|
`invalidate()` and an epoch back to zero. It must leave nothing of the old history behind, which
|
||||||
|
is why it clears the time ring, the absolute-time ring, every value ring, and both the published
|
||||||
|
window and the pyramid of every channel: a reused store that kept a stale published window would
|
||||||
|
hand the renderer samples the new plan does not describe.
|
||||||
|
`reset()` runs on the consumer thread under `ScopeModel::mutex()`, which is what makes a
|
||||||
|
`RingBuffer::clear()` safe here at all — the producer holds that same mutex for the whole of
|
||||||
|
`on_data()`.
|
||||||
|
Every allocation that *is* made says so: `[udpscope] buffer allocated: N channels, S s span,
|
||||||
|
T ms` on stderr. A start used to emit fifteen of those; two is a real start, and a count that
|
||||||
|
climbs again is this rule having stopped applying.
|
||||||
|
- **Enables are applied in one batch, because each one re-plans.** `set_signals_enabled()` takes
|
||||||
|
the mutex once, moves every signal in the list, and re-plans and bumps the generation **once** —
|
||||||
|
and not at all when nothing actually moved, which is the common case for a restored session
|
||||||
|
re-enabling what is already on. Calling `set_signal_enabled()` in a loop is a buffer rebuild per
|
||||||
|
signal; `apply_layout()` and the waveform loader collect their wanted signals into one vector for
|
||||||
|
that reason, and `apply_layout()` records the session's span **first**, with no channels bound, so
|
||||||
|
the `apply_time_window()` that follows re-plans nothing.
|
||||||
|
- **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.
|
||||||
|
- **A file is ingested too, so the loader decimates by the same factor.** `load_waveform_into_model()`
|
||||||
|
sets the target span to the recording's own length and enables every column, and the plan that
|
||||||
|
comes back may well decimate: 20 s of 10 channels at 500 kS/s is 10 M samples a channel, and the budget is
|
||||||
|
`--buffer-samples` (rounded down to a power of two, 8.39 M by default). The loader therefore reads `partition().time_stride` **after** the enables and pushes
|
||||||
|
one slot per that many rows, carrying the group's mean at the group's mean stamp — the packet path's
|
||||||
|
filter, written a second time because a file does not arrive in packets. Pushing every row instead
|
||||||
|
overruns the ring and leaves the file as **its own tail**, with nothing to say so: a 20 s event
|
||||||
|
opened as its last 4 s, `stored=10000000` against `published=2097152` in the channel dump, and a
|
||||||
|
trace that looks like a short recording rather than a truncated one. The `waveform loaded:` line
|
||||||
|
carries `rows=` and `decim=` beside `samples=` for exactly this reason — `samples * decim == rows`
|
||||||
|
is the arithmetic that says the whole file is in the buffer.
|
||||||
|
- **One bad packet is bridged; only a run of them changes the time source.** The stream-time
|
||||||
|
checks in `build_stamps()` reject three things: stamps that are not finite, a packet with no
|
||||||
|
span at all, and a packet whose stamps do not advance past the previous packet's last. A
|
||||||
|
fourth was added because a real device's clock glitches: a packet whose stamps jump **more
|
||||||
|
than 256 packet spans** ahead is a fault, not a gap, and must be rejected — letting it
|
||||||
|
through poisons `last_stamp_`, and the monotonic clamp (`max(stamps[k] - epoch,
|
||||||
|
last_stamp_)`) then pins every later packet flat at the glitched value, so the whole stream
|
||||||
|
freezes at one x and never recovers. A rejected packet is **bridged**: its stamps are
|
||||||
|
replaced by a continuation of the accepted timeline at the declared cadence, so neither the
|
||||||
|
monotonic mark nor `last_stream_anchor_` is poisoned and the next good packet is accepted
|
||||||
|
normally. Believing the glitch instead is what condemned the *next*, perfectly good packet
|
||||||
|
(it now sits below the poisoned anchor, so it fails the advance test) and threw the whole
|
||||||
|
stream onto the arrival clock. Only **four consecutive** rejected packets abandon the
|
||||||
|
declared time signal, because that is what a signal which is not a time base at all looks
|
||||||
|
like; a first packet with nothing to bridge from still falls back immediately. The unit
|
||||||
|
scale ('ns', 'us', 'ms') is therefore resolved **before** these checks rather than after:
|
||||||
|
they weigh the stamps against a cadence in seconds, and on a 'ns' stream an unscaled
|
||||||
|
comparison is off by 1e9, so every packet would read as an implausible jump. The guard is
|
||||||
|
`tools/mock_udpstreamer.py --time-glitch-at`/`--time-glitch-by` (see
|
||||||
|
[verification.md](verification.md)).
|
||||||
|
- **A *restarted* client is the other way into the same poisoning, and it is not a glitch.** Going live
|
||||||
|
from a file view starts a new `udps_client`, and the new stream's stamps can sit **behind** the ones the
|
||||||
|
old stream left in `last_stamp_` — the file view's samples are still in the store. The advance test then
|
||||||
|
rejects every packet of a perfectly good stream, four rejections abandon the declared time signal, and
|
||||||
|
the arrival clock derives its epoch from the stale mark: every sample is stamped **flat at the old
|
||||||
|
value**. `trig engine: … ring=[2.5200..2.5200] n=567000 → n=1131000 → n=1689000` is what that looks
|
||||||
|
like — the count climbing at the stream's rate while the retained span is one value — until some later
|
||||||
|
rebuild resets the store, which is where the "few seconds of nothing" came from.
|
||||||
|
`Acquisition::start()` therefore calls `ScopeModel::reset_stream()` and re-bases `last_stamp_` to
|
||||||
|
`-infinity`: a started or restarted client is a *new stream*, so the previous one's history goes (which
|
||||||
|
is what `reset()` is for) and the monotonic guard re-bases on the new clock instead of pinning it.
|
||||||
|
`ring` moving within a stream round-trip of *Go live* is the assertion.
|
||||||
|
- **The arrival-clock fallback does not drop history.** `arrival_epoch_` is derived from
|
||||||
|
`last_stamp_`, so the arrival clock *continues* from the monotonic mark and the two
|
||||||
|
timelines share a zero by construction — the recorded stamps are therefore still on one
|
||||||
|
timeline and are kept. The fallback used to call `reset_live_history_locked()` on the
|
||||||
|
reasoning that the old stamps belonged to a dead clock; they do not, and that wipe is what
|
||||||
|
made a single glitching packet look like "the live stream restarted from 0 with no action
|
||||||
|
from the user". The continuity is also what makes a rebase of `TriggerEngine` unnecessary:
|
||||||
|
the engine holds four absolute stamps (`holdoff_until_s_`, `pending_start_s_`,
|
||||||
|
`last_trigger_s_`, `last_t_s_`) and they stay valid across the change. Anything that makes
|
||||||
|
the clock *discontinuous* breaks that and has to reset them.
|
||||||
|
- **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.
|
||||||
|
- **Append is not the only write.** A backfill amends slots that a reservation wrote as NaN, and
|
||||||
|
the amend is split across threads for a reason:
|
||||||
|
- **Ring amend — producer thread.** `RingBuffer<T>::amend(absolute, v)` is a single relaxed
|
||||||
|
store, bounds-checked against `[produced_ - capacity, produced_)`. The same race class as a
|
||||||
|
push, and an `f32` cannot tear. Measurements and export read the ring directly, so they see
|
||||||
|
a repair immediately and no second notification is needed.
|
||||||
|
- **Pyramid amend — consumer (GTK main thread), inside `ChannelBuffer::acquire()`.** The
|
||||||
|
producer collects amended values in a plain `std::vector<Amend>` member and the consumer
|
||||||
|
drains it after its own append pass. `acquire()` is called under `model.mutex()`, and the
|
||||||
|
producer holds that same mutex for all of `on_data`, so the vector is correctly serialised
|
||||||
|
with no lock-free queue. The reason the pyramid amend cannot also be on the producer: a
|
||||||
|
bucket is a 32-byte non-atomic aggregate that the renderer reads **unlocked at paint time**,
|
||||||
|
so a producer writing it could be caught mid-bucket — a `count` that does not match its
|
||||||
|
`sum`, producing a wrong mean from a structure whose readers trust `count == 0` to mean
|
||||||
|
*absent*. On the consumer that cannot happen.
|
||||||
|
- **A hole is stamped at reservation; backfill changes values only.** The f64 time ring is
|
||||||
|
never amended. `neighbours()' binary search requires a monotonic array, and `window_x0_`
|
||||||
|
must remain valid; pre-stamping the hole at the declared cadence (exact in relative mode,
|
||||||
|
the bridge mechanism in stream mode) is what keeps both invariants, and it is why a late
|
||||||
|
packet's counter, not its arrival stamp, is what addresses the hole. The reservation cap
|
||||||
|
(`kGlitchPackets`, 256) is counted in **packets, not samples**, so one hole's sample cost
|
||||||
|
is `256 × batch_` and scales with the stream's batch — 25 600 samples at a batch of 100,
|
||||||
|
2.56 M at a batch of 10 000 — which is the number to weigh against the ring, not the 256.
|
||||||
|
- **A reservation is weighed against the wire, and never booked on the counter's word alone.**
|
||||||
|
The counter is a *claim* about the stream; the arrival clock is a *measurement* of it, and only
|
||||||
|
the two together justify writing NaN into the ring (`Acquisition::gap_plausible()`, called
|
||||||
|
before anything is booked *and* before relative mode's own clock advance, because the advance
|
||||||
|
and the reservation are the same claim). Two tests, either one refusing is enough:
|
||||||
|
- **The arrival clock.** `missing` packets would have taken `missing` frame intervals to arrive,
|
||||||
|
so a gap whose elapsed arrival time is under half of that is refused. Half and not one because
|
||||||
|
the socket clock jitters and a drain can compress two frames' arrival times -- and a refused
|
||||||
|
gap costs a *shading*, never data, so the test is deliberately permissive.
|
||||||
|
- **The ring.** `missing × batch_` samples become `missing × batch_ / time_stride` slots, and the
|
||||||
|
hole may not lap `time_capacity`. This is the *sample* form of the rule the 256-packet cap
|
||||||
|
states in packets -- the cap's own comment says 256 packet spans in one go is an outage, and
|
||||||
|
the invariant above says the number to weigh against the ring is `256 × batch_` and not the
|
||||||
|
256 -- and it has to exist in samples because a hole that laps the ring erases the history it
|
||||||
|
is meant to annotate, can never be backfilled (its slots are already overwritten) and is never
|
||||||
|
forgotten (`forget_holes_before()` waits for slots the ring has passed), so it leaves a
|
||||||
|
*permanent* band over a buffer that is all NaN.
|
||||||
|
What its absence looks like, which is why it is written down: a device whose counter counts
|
||||||
|
*samples*, or frames of an inner layer, reports a forward jump on every datagram while the wire
|
||||||
|
delivers on time. Every frame then reserves, and with `relative_time` every frame books NaN
|
||||||
|
slots by construction -- one band over the whole window with the samples washed out,
|
||||||
|
`backfilled=0` (no packet was ever missing, so nothing can repair it), and the status bar reading
|
||||||
|
`live` at the right rate the whole time. The refusal is counted in `StreamStats::gaps_refused`
|
||||||
|
and said once per connection with the numbers, because the status bar's `gaps` climbs either way
|
||||||
|
and the state is otherwise invisible; `tools/mock_udpstreamer.py --counter-step N` is the mock
|
||||||
|
shape (its counter is otherwise always +1, so this is the one wire shape it could not produce),
|
||||||
|
and `gaps_refused` rising while `nan_slots` stays 0 is the reading. A refused gap is not a silent
|
||||||
|
one: the timeline is not stretched over loss that did not happen, so it shows as the x
|
||||||
|
discontinuity it is.
|
||||||
|
- **A re-plan drops every hole and restarts the producer's tick.** `rebuild_locked()` replaces
|
||||||
|
the store, so `produced()` restarts at 0 and a hole's `slot_begin`/`tick_begin`/`x_begin`
|
||||||
|
describe a buffer that is gone; it therefore clears `holes_` and bumps
|
||||||
|
`ScopeModel::plan_generation()`, which the producer compares against the value it last saw
|
||||||
|
and resets `tick_` on. That is what keeps `tick_ / period == produced()`, the relation
|
||||||
|
`backfill_from()` derives a slot index from — without it the amend falls outside the ring
|
||||||
|
and repairs nothing while the hole is still marked repaired, a band saying the opposite of
|
||||||
|
the truth. The generation is the signal and the stride/channel-count comparison is not: a
|
||||||
|
reconnect with an identical CONFIG rebuilds the store leaving both unchanged.
|
||||||
|
- **The relative clock is accumulated in *seconds*, and a frame is only stamped into a model that has a
|
||||||
|
streamed channel.** Relative time's stamp is the declared interval counted from the stream's own
|
||||||
|
origin — `Acquisition::rel_base_s_`, which is advanced by the frame's own samples and by the samples a
|
||||||
|
reported gap spans. It is deliberately **not** `position × Partition::base_dt`, and the difference is
|
||||||
|
one defect: a plan is rebuilt whenever the channel list changes (a CONFIG, an enable/disable, a
|
||||||
|
session's math channels), and `base_dt` is the **1 kHz fallback** until a *streamed* channel declares a
|
||||||
|
rate. Recomputing from a position made a plan that *shortened* the interval put every later stamp
|
||||||
|
behind the one already written, and the frame loop's monotonic clamp then held the clock there for the
|
||||||
|
rest of the run. What that looks like: `ring=[a..a]` standing still while `n` climbs,
|
||||||
|
`history` never reaching the pre-window (so a trigger never latches and the display stays in `Waiting`,
|
||||||
|
which is blank), and — the reading that names it — `[udpscope] warning: the clock stalled ...`, printed
|
||||||
|
once per connection the first time a whole frame's stamps are all behind the clock. Accumulated, a plan
|
||||||
|
change is only a slope change and the clock cannot go backwards. The entry is the *other* half: the
|
||||||
|
ingest used to accept a frame as soon as `channels()` was non-empty, and a session's math channel
|
||||||
|
applied before the stream's own signals arrive is exactly one channel with a fallback plan. Such a
|
||||||
|
frame has nowhere to go (the value loop skips every derived channel) and its plan's interval is not the
|
||||||
|
stream's rate, so `on_data()` now requires a **streamed** channel and drops the frame otherwise, the
|
||||||
|
same way it always did for an empty list. `rel_base_s_` is **not** reset across a reconnect — the
|
||||||
|
origin survives, it is relative mode's clock origin — while `base_dt`'s *use* is what this rule took
|
||||||
|
away: it is read to *advance* the clock, never to recompute where the clock already was.
|
||||||
|
- **The stream's epoch is adopted *once per connection*, and "unset" is a state, not the value zero.**
|
||||||
|
`epoch` is the stream time the display's x = 0 means, taken from the first frame's stamp. The test for
|
||||||
|
"not adopted yet" used to be `model.epoch() == 0.0`, which relative mode breaks by construction: its
|
||||||
|
first frame is stamped exactly `0`, so the *second* frame adopted the origin — and with
|
||||||
|
`rel = max(stamp - epoch, last_stamp_)` that put a whole frame's samples on one stamp and shifted every
|
||||||
|
later x by a frame's span. `Acquisition::epoch_adopted_` is the flag, re-armed by
|
||||||
|
`reset_session_state()` because a new session's clock is a new clock; the warning line above is what
|
||||||
|
caught it.
|
||||||
|
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## The packet capture sits at `recv()`, and its file is reserved, not sparse
|
||||||
|
|
||||||
|
Every wire behaviour a forensic capture exists to answer — a counter that skips, a datagram that
|
||||||
|
arrives twice, a fragment that never completes, a CONFIG whose header does not parse — is
|
||||||
|
invisible by the time a `udps_frame_t` reaches `Acquisition::on_data`: the client has already
|
||||||
|
validated, reassembled, resequenced and dropped. The capture therefore hooks
|
||||||
|
`udps_client_set_datagram_cb()`, which the client fires immediately after the UDP `recv()`
|
||||||
|
returns and before anything reads out of `rxbuf`, and it stores the bytes without interpreting
|
||||||
|
them. The capture is of the *UDP socket*: in multicast mode CONFIG arrives over TCP and is out
|
||||||
|
of reach.
|
||||||
|
|
||||||
|
The ring file is reserved with `posix_fallocate` rather than left sparse because the stores are
|
||||||
|
`memcpy`s into an `mmap`: a store into a page the filesystem cannot back raises **SIGBUS**, which
|
||||||
|
kills udpscope outright. An out-of-space condition has to be an error at the moment the user
|
||||||
|
toggles — reported in the popover row, the button back off — not a crash ten minutes into the
|
||||||
|
run the capture was switched on for. Within the reserved file, a slot's trailing `seq` word is
|
||||||
|
published with a release store *after* its bytes are down, so a slot carrying a non-zero `seq`
|
||||||
|
is complete and no `fsync` is needed for the file to be readable after a crash: the kernel's
|
||||||
|
dirty pages are the file, and a half-copied slot still reads `seq == 0`.
|
||||||
|
|
||||||
|
## A reconnect with an unchanged CONFIG keeps the buffer
|
||||||
|
|
||||||
|
A disconnect -- EMI makes it a silence timeout, not a polite DISCONNECT -- must not cost the
|
||||||
|
acquisition. What historically did was the reconnect's CONFIG: `ScopeModel::set_signals()` rebuilt
|
||||||
|
the store on *every* CONFIG, and a rebuild is what drops history, resets `tick_`, retires the
|
||||||
|
holes and invalidates a held event. Now the incoming CONFIG is compared against the stream the
|
||||||
|
model already has -- names, shapes, rates, ranges, time modes, time-signal indices -- and an
|
||||||
|
**identical CONFIG returns early**: the ring keeps its history, the trigger keeps its state and
|
||||||
|
its held event, and the outage shows as the gap in the data that it was. Only a CONFIG that
|
||||||
|
*changes* the stream re-plans.
|
||||||
|
|
||||||
|
The producer's own state is per *connection*, and carrying it across a drop makes recovery read
|
||||||
|
as failure: `Acquisition::reset_session_state()` (on DISCONNECTED, and again on the next CONFIG)
|
||||||
|
clears the counter mark, the stream-time anchor and the smoothed arrival interval, so the first
|
||||||
|
frame of the new session re-anchors instead of reading as late, as a glitch or as an outage. The
|
||||||
|
arrival clock's *anchor* deliberately stays -- stamps are small seconds the ring's timeline is
|
||||||
|
already scaled against, and re-anchoring mid-stream would move x. The clock *choice* is re-derived
|
||||||
|
per connection (`time_source_ = 0` on CONFIG) for the same reason: a session that dropped may
|
||||||
|
come back with a different answer.
|
||||||
|
|
||||||
|
Relative time's clock (`Acquisition::rel_base_s_`, seconds accumulated at the declared interval)
|
||||||
|
also survives: it is the user's chosen origin, and must not jump backwards because the link blipped.
|
||||||
|
|
||||||
|
## There may be no store at all
|
||||||
|
|
||||||
|
`rebuild_locked()` calls `store_.reset()` for a stream with **no channels** — the model's state until the
|
||||||
|
first CONFIG arrives — so `store_` is legitimately null, and `ScopeModel::live()` dereferences it. Every
|
||||||
|
path reachable without a stream has to ask first: **pausing a scope that is not connected** is one (the
|
||||||
|
button works, and freezing a view means taking a window from the rings), and it crashed inside
|
||||||
|
`live_window_locked()` on a null store. The guards belong at the entry points the UI can reach
|
||||||
|
(`live_window_locked()`, `publish_pause_view()`), not in `live()`, which cannot express "nothing there"
|
||||||
|
through a reference. The paths that only run for a *bound* trace — the series' gather, the SlotSeries'
|
||||||
|
`shared_channel()` — are safe by construction: a slot with no channel binds no series at all.
|
||||||
|
`UDPSCOPE_PROBE_PAUSE_MS` against a port nothing is listening on is the check.
|
||||||
|
|
||||||
|
## A waveform is a source that never arrives
|
||||||
|
|
||||||
|
`--open FILE` (and the chooser's Waveforms page) puts an exported `.npy` into the model instead of a
|
||||||
|
stream: one signal and channel per column, the samples pushed through `push_loaded_slot()` — the
|
||||||
|
producer's own order (values, the shared clock, the absolute clock, then the derived channels), under
|
||||||
|
the model's lock — and the display epoch pinned to the file's last stamp so the axis reads
|
||||||
|
`[-window, 0]` exactly as the export did. Everything downstream is unchanged, which is the point:
|
||||||
|
cursors, measurements, math channels and another export work on a file because they work on the
|
||||||
|
rings, not on the wire.
|
||||||
|
|
||||||
|
Three things follow, and each is a place the live assumptions would otherwise leak:
|
||||||
|
|
||||||
|
- **Nothing is ever starved.** There is no socket and no source, so the starvation notice is skipped
|
||||||
|
while a waveform is open, and the popover's Stream row names the file rather than reporting a
|
||||||
|
connection that has not answered.
|
||||||
|
- **The loader must not hold the model's mutex.** Every setter it needs (`set_signals`,
|
||||||
|
`set_signal_enabled`, `set_target_span`) takes that same non-recursive lock; the first version
|
||||||
|
wrapped the whole load in it and deadlocked on the first call. The per-slot push is a model method
|
||||||
|
for the same reason.
|
||||||
|
- **The signal names come from the file.** A `.npy` written by `write_npy()` is a structured array
|
||||||
|
whose dtype names its fields, so an export is self-describing and there is no sidecar to lose.
|
||||||
|
`.csv` is not read: it carries the names too, but as text, and the item asked for the binary one.
|
||||||
|
- **A waveform has no source, and the app must not invent one.** The address the command line
|
||||||
|
*defaults* to (`127.0.0.1:8000`) is not one: leaving it in place made a file-mode run load — and
|
||||||
|
then *save* — that source's session, which overwrote the arrangement the file was opened with
|
||||||
|
(every drop then refused, the row full) and could clobber a real session. `App::source_port` is 0
|
||||||
|
in file mode, which is what makes `save_layout_now()` refuse and the first-CONFIG layout load skip
|
||||||
|
— the same two things that make a source-less run safe.
|
||||||
|
- **One row per column.** Four columns to one row fills the default single row and then refuses
|
||||||
|
every drop ("plot 1 already holds four signals"), which is a loaded file the user cannot plot
|
||||||
|
anything *else* on or rearrange. One per row (sized in `main.cpp`, before the UI exists, as
|
||||||
|
`--plots` does) leaves three free slots each, shows a four-signal file as four stacked traces, and
|
||||||
|
keeps the rest of the columns draggable. Sizing the rows from inside the activation instead leaves
|
||||||
|
the panels built for the old count and the app hangs on the first relayout.
|
||||||
@@ -0,0 +1,759 @@
|
|||||||
|
# Invariants: GTK traps, the status bar, the icons and the manual
|
||||||
|
|
||||||
|
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 how the Mallard manual is installed and opened.
|
||||||
|
|
||||||
|
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.
|
||||||
|
- **The trigger circle is the badge's sibling, in its alphabet and under its rules.** It sits
|
||||||
|
before the badge on the status row (`main-window.ui` `trigger_status`) and says what the
|
||||||
|
badge cannot: the *trigger's* state -- solid green while an event is held or a capture is
|
||||||
|
filling, flashing green (the lit and dim halves) while armed and waiting, red while the
|
||||||
|
trigger mode is on but disarmed, and **hidden while the trigger mode is off**, because a
|
||||||
|
light for a mode the user is not in is noise and the mode text beside it shows no trigger
|
||||||
|
words then either. Same width-locked glyph in every state, same half-second square for the
|
||||||
|
flash, markup rewritten on a change of the state key only (`trigger_circle_state`), and the
|
||||||
|
exit dump reads the tick's cached answer (`dbg_trigger_circle`) because it cannot ask the
|
||||||
|
widget.
|
||||||
|
- **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.
|
||||||
|
- **`MeasureDialog` and `MathDialog` are brace-initialised *positionally* in `src/ui.cpp`**, so a field
|
||||||
|
added in the middle of either silently shifts every argument after it: add at the end of the widget group
|
||||||
|
and read the initialiser in the same commit. A row array widened by a new control is the same trap from
|
||||||
|
the other side -- a `rows[N]` that was not grown shows a control the sync never touches, and the compiler
|
||||||
|
says so as an out-of-bounds warning on `rows`.
|
||||||
|
- **A control that cannot affect the current type is *hidden*, not disabled**: the trigger's band row is
|
||||||
|
hidden for `dropout`, the measurement's for every non-crossing type, and the node editor's median row for
|
||||||
|
every op but `de-noise`. A greyed row is a question the user can see and cannot answer.
|
||||||
|
- **`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, Configurations) and the one editor that is not a pane (the layout editor): the signal list
|
||||||
|
is the `signals` slide-over page, so there is nothing left to reveal or hide about it. The Arm/Disarm button in the trigger panel still has text.
|
||||||
|
- **An overlay handle that follows what it drags reports gesture offsets in its own moving
|
||||||
|
coordinates; add its displacement back.** A `GtkGestureDrag` measures its offset from where the
|
||||||
|
press began *in the widget's own coordinates*. The layout editor's column handle sits on the
|
||||||
|
divider and `sync_layout_editor()` moves it every frame, so after a few motion events the
|
||||||
|
offset is the pointer's travel **minus** the handle's travel, and the divider converges on half
|
||||||
|
the pointer's travel (`D = P - D`). Record the handle's x in the overlay's coordinates at
|
||||||
|
begin (`gtk_widget_compute_point`) and add `handle_now - handle_begin` to the offset on every update
|
||||||
|
(`layout_col_total_offset()`). The same applies to any future handle that is repositioned
|
||||||
|
from the state its own drag changes.
|
||||||
|
- **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,events,resize-rows,resize-cols}-symbolic.svg`
|
||||||
|
are the six action icons. `udpscope-math-symbolic` left with the toolbar toggle that was its last
|
||||||
|
user; `udpscope-measure-symbolic` **stayed**, because the Preferences window's Plot page carries it --
|
||||||
|
an icon with a live user is not an orphan, whatever the toggle that introduced it did -- and it is
|
||||||
|
the Preferences window's icon and the **Dock toggle's** (the ruler: the dock is the readouts).
|
||||||
|
- **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 and the shortcut overview are opened by `show_about()` and `show_shortcuts()`,
|
||||||
|
and each closes with the matching call; the manual is *not* one of these.** `show_help()` hands
|
||||||
|
the `help:<document>` URI to the desktop help viewer (`GtkUriLauncher`, i.e. Yelp) — a process of
|
||||||
|
its own that cannot be closed from here — because the manual is a Mallard set installed for the
|
||||||
|
freedesktop `help:` URI, not a GResource in this binary (see the help bullet below). So there is
|
||||||
|
exactly **one `GtkWindow`** among *that pair*: the shortcut overview. 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. **A `close-request` handler's return value is load-bearing
|
||||||
|
too, and its absence is silent**: the signal is `gboolean`, `TRUE` means "handled" and suppresses
|
||||||
|
the default handler that destroys the window, and `G_CALLBACK` is a cast — so a `void` lambda in
|
||||||
|
the slot compiles without a warning and GTK reads whatever the compiler left in the return
|
||||||
|
register as the verdict. The Configurations dialog (`open_configurations()`) was written that
|
||||||
|
way, and the built binary returns non-zero from the lambda, so **both its Close button and the
|
||||||
|
window manager's close button emitted `close-request`, were told "handled", and the dialog stayed
|
||||||
|
up** — "the configurations dialog cannot be closed", which no reading of the callback's type can
|
||||||
|
see. Every `close-request` handler therefore returns `FALSE` explicitly (the chooser's
|
||||||
|
`on_chooser_close()`, the overview's `on_window_close()`, the math dialog's, and this one). The About dialog (an `AdwAboutDialog`) is an **`AdwDialog`
|
||||||
|
and not a `GtkWindow`**: it closes with `adw_dialog_close()` and clears on `::closed`, there being
|
||||||
|
no `::close-request` on an `AdwDialog`. It shares one `on_dialog_closed()` handler with the About
|
||||||
|
pointer, keyed by a **pointer to the pointer** it must null (`&g_about`), which keeps the handler
|
||||||
|
honest without knowing which dialog it is. One `GtkWidget*` (or `AdwDialog*`) per window, nulled
|
||||||
|
in its own close handler, is the shape that prevents the double-open defect. `close_help_windows()`
|
||||||
|
(misnamed now) closes just the two dialogs; a failed *launch* is reported by the launcher's own
|
||||||
|
callback with a `GtkAlertDialog`, so a click that cannot open the manual says so. That callback is
|
||||||
|
also `show_help()`'s **optional reporter** (`std::function<void(bool ok, const std::string& error)>`)
|
||||||
|
— the one hook that runs on the success path, where no window of ours is left to observe; the probe
|
||||||
|
uses it and nothing else does.
|
||||||
|
- **`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 a Mallard file, and the manual is *not* in the binary.** `help/C/<id>/` holds
|
||||||
|
`index.page`, one `<topic>.page` per subject, and the `figures/*.png` the pages link, and the
|
||||||
|
whole set is installed (the `install()` rule in CMakeLists) to `<datadir>/help/C/<id>/` — the
|
||||||
|
path the freedesktop Help System Specification's `help:` URI resolves `help:<id>` to. The
|
||||||
|
document identifier is *derived from the desktop file's basename* (`org.udpscope.app.desktop.in`
|
||||||
|
→ `org.udpscope.app`), so it is the `.desktop` name, the GApplication id and the install
|
||||||
|
directory at once; `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that one
|
||||||
|
place. `show_help()` opens `help:<id>` with `GtkUriLauncher` (Yelp runs in its own process, so
|
||||||
|
nothing here holds a pointer to it), and its failure callback shows a `GtkAlertDialog`. That
|
||||||
|
failure is **careful about which thing is missing**: `gtk_uri_launcher_launch_finish()` reports a
|
||||||
|
launch that could not be handed over — the *handler's* absence — and cannot tell that from a
|
||||||
|
document that was never installed, so `help_installed_path()` walks the specification's own
|
||||||
|
`$XDG_DATA_HOME/help/<lang>/<id>/` then `$XDG_DATA_DIRS/help/<lang>/<id>/` for an `index.page` and
|
||||||
|
the dialog says which one it is: with the manual there, install a viewer (for example `yelp`); with
|
||||||
|
it absent, the identifier and the install command, the **system-wide** form first
|
||||||
|
(`cmake -DCMAKE_INSTALL_PREFIX=/usr .. && sudo cmake --build build --target install-help`), the
|
||||||
|
no-root `install-help-user` second with its caveat (see the location rule below) — and a
|
||||||
|
viewer as well, the second possibility the error alone cannot rule out. Reporting a missing
|
||||||
|
*document* as a missing *handler* is the defect that shape avoids.
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_HELP_MS` launches it too, and its `[help] launch attempted= done= ok= error=''
|
||||||
|
installed=` line is the reading — `ok=1` is the URI handed to a viewer and not a window on screen,
|
||||||
|
since Yelp is another process and cannot map one headlessly; `installed=` sits beside it because a
|
||||||
|
failing `ok=0` has to be read against whether the document was there at all.
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_HELP_MS` no longer parses pages — they are files, not markup in a resource — so
|
||||||
|
its `[help]` line asserts the set is *there*: `topics` (the `.page` files other than
|
||||||
|
`index.page`), `index`, and `figures`, scanned from `UDPSCOPE_HELP_DIR`. `topics=0 index=0` means
|
||||||
|
the manual is missing from the tree, which is the finding the old `parsed != pages` used to name.
|
||||||
|
`cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files:
|
||||||
|
`yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render.
|
||||||
|
`yelp-check validate` is deliberately absent — it fetches the Mallard RelaxNG schema over the
|
||||||
|
network and fails offline for that reason, not for a fault in the pages. The figures come from
|
||||||
|
`tools/make-help-shots.sh`, which drives the app headlessly against the mock and annotates a grab
|
||||||
|
(GTK4 must be forced to the Cairo renderer, `GSK_RENDERER=cairo`, or the X11 surface reads back
|
||||||
|
blank). Mallard is translatable with `itstool`, but there is no i18n set-up here: only the `C`
|
||||||
|
set is authored.
|
||||||
|
|
||||||
|
**A user-local help set is one Yelp cannot draw the figures of, so the system-wide install is
|
||||||
|
the one to recommend — and this is a *location* rule, not a markup or a file rule.** Yelp
|
||||||
|
sandboxes its WebKit subprocess, and that sandbox exposes the system help directories
|
||||||
|
(`/usr/share/help`, `/usr/local/share/help`) but not `$XDG_DATA_HOME/help`: the pages load,
|
||||||
|
their `<media>` figures do not, because each figure is a *separate file* the page references
|
||||||
|
rather than something embedded in it. The symptoms read as anything but a location problem —
|
||||||
|
opening a page *as a file* renders it with no images, and opening it through the app's `help:`
|
||||||
|
URI leaves the viewer spinning **for ever**, the failed image fetch meaning the page's load
|
||||||
|
event never fires while the sidebar title still updates, which looks like a navigation failure
|
||||||
|
rather than a missing resource. A shipped GNOME page that carries a figure
|
||||||
|
(`/usr/share/help/C/accerciser/api_browser_plugin.page`) draws either way, which is what
|
||||||
|
isolates the cause to where the set *lives*. `make install-help` (into `/usr/local`) followed
|
||||||
|
by `yelp /usr/local/share/help/C/<id>/<topic>.page` works; the same set under
|
||||||
|
`~/.local/share/help` does not. Two genuine defects were chased and fixed before the location
|
||||||
|
was found — a `<figure>` with no `<desc>` and a `bKGD` chunk the PNG spec forbids on an
|
||||||
|
alpha image — so do not read a figure that will not draw as a repeat of either, and do not
|
||||||
|
revert those fixes: this is why both the failure alert in `src/help_window.cpp` and the
|
||||||
|
manual's own `overview.page` name the system-wide install first and state the user-local one's
|
||||||
|
caveat beside it.
|
||||||
|
- **The chooser carries the same help affordances as the window, and the tour request crosses as a
|
||||||
|
value — not a global.** The source chooser runs *before* `App` exists, so a user who has not started
|
||||||
|
yet is the one least able to find the help; its header bar therefore has a help `GtkMenuButton`
|
||||||
|
(the `.ui` holds the button, code holds the rows) whose three entries are the window's Help view's
|
||||||
|
the same way — `ui_impl::menu_button()`/`menu_toggle()`, the app's own row shape — with the
|
||||||
|
`.udpscope-menu` class on the popover. That class's rules only exist once `install_css()` has run, so
|
||||||
|
`choose_source()` calls it before building the rows (`install_css()` is idempotent precisely because
|
||||||
|
it now has two callers; the app's `on_activate()` still calls it and is a no-op by then). The rows
|
||||||
|
show **no accelerators**: the window's Help view prints `F1`/`Ctrl+T` because the window binds them,
|
||||||
|
and this dialog binds none, so a key printed here would be documentation that does nothing.
|
||||||
|
*Documentation* calls `show_help()` straight from the chooser (the manual is another process and
|
||||||
|
needs nothing of the app); *Enable contextual help* writes the settings file, which is what the app
|
||||||
|
reads when it starts a moment later, and **re-syncs this dialog's own "(i)" rows** through
|
||||||
|
`chooser_help_sync()`; *Guided tour* starts a tour **of this window** -- the chooser's own walk --
|
||||||
|
and that walk's last stop sets **`Config::tour_pending`**, the one value `main()` owns and hands to
|
||||||
|
both `choose_source()` and `run_ui()` (`open_waveform` and the preferences' relative time cross the
|
||||||
|
same way). `on_activate()` schedules `tour_start_requested()` 1.5 s later, the same delay the
|
||||||
|
first-run auto-start uses, and **that callback is separate from `tour_autostart()` on purpose**: it
|
||||||
|
sets `tour_requested`, not `tour_auto_started`, so `help layer:` says which of the two started the
|
||||||
|
window's half instead of conflating a user's request with a first run. The chooser's half has its own
|
||||||
|
entry points in `source_dialog.cpp` (`chooser_tour_begin`, `chooser_tour_autostart`), and *that* walk
|
||||||
|
is what a first run meets first -- see the tour bullets in the help-affordance section below.
|
||||||
|
- **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.**
|
||||||
|
(The events pane that taught this is gone -- its list is a slide-over page now, with no divider -- but
|
||||||
|
the rule holds for any paned.) With the
|
||||||
|
position left alone, `shrink-start-child` or not, a closed side child's minimum is reserved as an empty
|
||||||
|
column, and the plot pays for a pane nobody opened. The position is the only knob that
|
||||||
|
says "no room": store the width when it is closed (reading `gtk_paned_get_position()` *before*
|
||||||
|
zeroing it) and set 0, restoring it when the pane opens. `gtk_paned_set_shrink_start_child(TRUE)` is
|
||||||
|
still needed, or the minimum would clamp even a zero position.
|
||||||
|
- **An unrevealed `GtkRevealer` measures 0x0 and is still given the leftover width.** `reveal_child
|
||||||
|
= FALSE` makes both the revealer's minimum and its natural size zero -- and its box then hands it
|
||||||
|
everything the expanding sibling beside it did not claim, so a *collapsed* pane reserved a
|
||||||
|
310-pixel empty column between the window edge and a plot drawn 630 pixels wide in a 1270-pixel
|
||||||
|
window. (That pane was a revealer beside the plot; it is a slide-over page now, so no revealer sits in
|
||||||
|
a horizontal box any more.) Every vertical revealer in the side panel hid this, because there the leftover goes down
|
||||||
|
instead of across. `gtk_widget_set_visible()` is what a layout honours, so anything that must not
|
||||||
|
take room has to be hidden and not merely unrevealed; the revealer keeps the slide while the pane
|
||||||
|
*is* open. The allocated width is how this was found -- the revealer's own measure said 0.
|
||||||
|
**The sidebar is one dock now, and has no `GtkRevealer` left in it**: a single `GtkScrolledWindow`
|
||||||
|
(`dock_scroller`) holding only the compact **readouts** (`src/ui_dock.cpp`). It
|
||||||
|
replaced an `AdwViewSwitcher` over an `AdwViewStack`, whose four text tabs did not fit 320 px (and a source
|
||||||
|
can add more); a source kind's own panels are slide-over pages, not dock sections. The reason is the one
|
||||||
|
this trap teaches: full panels competing for one narrow column each left every one of them short of room.
|
||||||
|
The full cursors and measurements panels are now the `cursors` and `measure` **slide-over pages**, reached
|
||||||
|
by the readouts' **Edit** buttons, and `m` toggles `measure` (it no longer scrolls the dock;
|
||||||
|
`dock_show_section()` is gone); the signal list is the `signals` slide-over page, which `g` and the `+` badge
|
||||||
|
open. The header's Cursors toggle no longer opens anything: it only enables the cursors. A *feature's* on/off is no longer a visibility at all -- the
|
||||||
|
**cursors'** toggle sets its pane's **body** insensitive (and the dock's cursor grid beside it) -- the readout has nothing to show with the
|
||||||
|
instruments off -- and the *body* and not the pane, because the pane's heading carries its own switch
|
||||||
|
(see the next bullet). The **trigger's body is deliberately never disabled**: a trigger is configured
|
||||||
|
*before* it is switched on, so switching it off to change it, or to read what it is set to, is the
|
||||||
|
ordinary way to work. It used to go insensitive with the mode, which left the settings unreachable
|
||||||
|
exactly when a user wanted to change them; `trigger panel sensitive …=1` is the regression guard, and a
|
||||||
|
`0` there means the configuration has been locked out again. The
|
||||||
|
trap itself does not go away with the widget: a section scrolled out of view is still laid out, but
|
||||||
|
**ask about state, not about size** still holds for anything a tick reads.
|
||||||
|
- **The dock is shown but insensitive until the stream has signals ("disable, do not hide").**
|
||||||
|
`update_side_visibility()` and the tick's per-frame block set the column's visibility from the user's wish
|
||||||
|
alone (`sidebar_wanted`) and the dock's *sensitivity* from `have_signals`; the toolbar's Dock button stays
|
||||||
|
sensitive throughout. The `cursors` and `measure` **pages follow the same condition** through
|
||||||
|
`update_signal_availability()` (called from `update_side_visibility()` and the per-frame block), so a
|
||||||
|
page opened by `m` before the stream has signals is insensitive, not refused. `[readouts] ... pages_sensitive=`
|
||||||
|
reads it. The wish is persisted (`dock = true|false` in the session file) and a change of it
|
||||||
|
schedules a save. `dock:` in the dump reads the result.
|
||||||
|
- **The signal badge is the drag handle, and a drop handler only moves widgets.** Dragging a signal onto a plot
|
||||||
|
starts on its badge in the badge bar (a `GtkDragSource` on the badge's button, payload the signal index as a
|
||||||
|
decimal string, taken by the plot area's existing drop target); the list's rows are not drag sources. A drag
|
||||||
|
starting with the slide-over open would have part of the plot hidden under the panel, so the source's
|
||||||
|
`drag-begin` closes it (which only restyles the badges: nothing is destroyed mid-drag). A click without movement
|
||||||
|
never reaches the drag threshold and still opens the page; past it the source claims the sequence and the
|
||||||
|
button's click is cancelled. Each badge is also a `GtkDropTarget` for reordering: `on_badge_drop` calls
|
||||||
|
`gtk_box_reorder_child_after` and rewrites `App::badge_order` from the widgets, and **never rebuilds the badges** --
|
||||||
|
the dragged badge and the target whose `drop` emission is running must both survive it. A foreign drag whose string is not a
|
||||||
|
signal index is refused. `[sigbadges]` reads the controllers, the payload and the reorder; `pane drag:` reads the close.
|
||||||
|
- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The
|
||||||
|
trigger pane's `[edit]` button is `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic`
|
||||||
|
(the check: done). The done one was `emblem-ok-symbolic`, which breeze ships and **Adwaita does not** —
|
||||||
|
so the button that leaves configuration mode was blank, which no other reading could see: the button
|
||||||
|
worked, the layout was unchanged, and only `pane edits:` (the two names and
|
||||||
|
`gtk_icon_theme_has_icon()`'s verdict, clicked into each mode) would say so. Check a name against
|
||||||
|
`/usr/share/icons/Adwaita` before using it; a name found under `breeze` is not an answer.
|
||||||
|
- **A pane's heading switch and the header bar's button are one control in two places.** Each pane's
|
||||||
|
heading (`build_trigger_panel()` builds the trigger's, `cursors-panel.ui` has the cursors' in its
|
||||||
|
`head`) carries a `GtkSwitch` that runs the *same action* the toolbar button runs, and
|
||||||
|
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change — so clicking
|
||||||
|
either end moves both, and `panel switches: trigger=1/1 cursors=1/1` is the reading. Two traps:
|
||||||
|
a `GtkSwitch` emits `notify::active` for a *programmatic* write as well, so the sync holds a guard
|
||||||
|
(`App::panel_switch_sync`) or the program's own write comes back as a click; and the switch must
|
||||||
|
stay *out* of what the pane disables, because a control inside an insensitive parent cannot be
|
||||||
|
clicked — a switch that greyed out with its own pane could never turn it back on. That is why the
|
||||||
|
sensitive target is the pane's **body** (`trigger_body`, `cursors_body`) and not the pane.
|
||||||
|
- **A list box outlives the children of the pane it sits in, and the teardown order is not yours to
|
||||||
|
choose.** Tearing the window down finalizes the events page's buttons, then its list box, and the
|
||||||
|
list's own `selected-rows-changed` then reaches a handler holding the freed pointers (`update_group_buttons()`
|
||||||
|
therefore keeps its `app.window == nullptr` guard): four
|
||||||
|
`Gtk-CRITICAL`s from `gtk_widget_set_sensitive` and a read of memory nobody owns. `close-request` is
|
||||||
|
*not* the hook -- `UDPSCOPE_EXIT_AFTER_MS` ends the self-check with `gtk_window_destroy()`, which does
|
||||||
|
not emit it. The hook that does work is `App::window`: every teardown path clears it *before* the
|
||||||
|
widgets go (the exit probe says so in a comment: "stop the tick before the widgets go"), so a handler
|
||||||
|
that can fire during teardown checks that first, rather than asking GTK whether a pointer is still a
|
||||||
|
widget.
|
||||||
|
- **A `GtkPicture`'s natural size *is* its content, so a size request on it is a floor, not a cap.**
|
||||||
|
A 480x160 texture in a row of a list claims the row's whole width and squeezes the text beside it
|
||||||
|
out of the row -- the box gives a non-expanding child its natural size and grows it further when
|
||||||
|
the expanding child has nothing left to take. Give the picture a **frame** whose size request equals
|
||||||
|
the **scaled texture's** size (natural == minimum, which is what stops the box growing it), and
|
||||||
|
scale the texture once at decode: it is also what keeps a per-row cache at 12 KB per picture rather
|
||||||
|
than 300.
|
||||||
|
- **A pointer to a widget inside a list survives the list being rebuilt.** `events_thumb` named a row's
|
||||||
|
picture across `refresh_events_pane()`, which removes every row first: the next reader of that
|
||||||
|
pointer got a `Gtk-CRITICAL` from `gtk_picture_get_paintable` on freed memory (the four criticals
|
||||||
|
read as "the page is empty"). A rebuild has to null what it is about to destroy, not only rebuild it
|
||||||
|
(`rebuild_events_pane_list()`, the page half of `refresh_events_pane()`, nulls `events_thumb` before it removes the page list's children). A handler on a
|
||||||
|
row that rebuilds the list destroys the widget it was emitted from: `outlive_the_rebuild(widget)`. The dock's
|
||||||
|
rows are a second list built from the same records; the dock row's click handler is safe only because
|
||||||
|
`replay_event()` never rebuilds the dock list.
|
||||||
|
- **`gtk_picture_set_filename()` can render nothing, silently.** It hands the file to the image
|
||||||
|
loader's sandboxed, asynchronous decoder, which on this machine fails without a message: the
|
||||||
|
picture keeps a *paint-able* and takes an allocation, so a probe that checks
|
||||||
|
`gtk_picture_get_paintable()` reads success while the page shows an empty rectangle (the event
|
||||||
|
page's preview did exactly that). `gdk_texture_new_from_filename()` decodes the same file on this
|
||||||
|
thread and `gtk_picture_set_paintable()` hands over the result -- and a NULL texture at least says
|
||||||
|
so. A picture that must be on screen is also a picture with a **height request**: `can_shrink`
|
||||||
|
alone asks for no height at all in a box.
|
||||||
|
- **A cache that is the *only* source of a widget's content must be created by the thing that reads
|
||||||
|
it.** The events page's thumbnails (the dock's rows have none) come from an `EventThumbCache` hung off the page's list box with
|
||||||
|
`g_object_set_data_full`, and `event_preview_texture()` answers out of that cache or not at all.
|
||||||
|
Nothing ever created it — so for as long as the list existed, **every** row came up without a
|
||||||
|
picture, which is what "event screenshot are unusable and sometime missing" was. A missing cache
|
||||||
|
there is not a slow path, it is a blank one; `event_thumb_cache()` therefore creates one on first
|
||||||
|
use. Owned by the **list**, because nothing else has the page's lifetime: textures of a list that
|
||||||
|
no longer exists are so much held memory, and the destroy notify is where they are unref'd.
|
||||||
|
- **Hover magnification is a tooltip, not a popover of ours.** A row's thumbnail is 138 px wide and
|
||||||
|
the stored preview is 480: the small one says *which* event this is, and the zoom says what is in
|
||||||
|
it. GTK's own tooltip (`gtk_widget_set_has_tooltip` + `query-tooltip` + `gtk_tooltip_set_custom`)
|
||||||
|
is shown, placed, dismissed **and destroyed** by GTK, and takes no pointer grab. A popover per row
|
||||||
|
would be a parented widget to unparent on every rebuild of the pane — one defect below — and a
|
||||||
|
grab to lose, for a thing with no interaction in it. Two details: `can_shrink` must be FALSE on the
|
||||||
|
tooltip's picture or the tooltip is given the smallest size its content allows (the opposite of the
|
||||||
|
point), and the path handed to the handler has to be **owned by the closure**
|
||||||
|
(`g_signal_connect_data` with a destroy func), since the record it came from is a loop variable.
|
||||||
|
- **Unparenting a popover inside its own `closed` emission is a use-after-free.** Dismissals go
|
||||||
|
through `gtk_popover_popdown()`, which emits `closed`; a handler that unparents the popover there
|
||||||
|
drops the reference the parent held, the widget dies, and GTK's own popdown path then runs on freed
|
||||||
|
memory — `gtk_popover_get_autohide: assertion 'GTK_IS_POPOVER (popover)' failed`, on every menu
|
||||||
|
entry that dismisses its popover, which is every entry there is. `on_popover_closed()` therefore
|
||||||
|
defers the unparent by one idle and holds a reference across it
|
||||||
|
(`g_idle_add_full(..., g_object_ref(popover), g_object_unref)`), so the popover outlives the
|
||||||
|
emission that dismissed it.
|
||||||
|
- **`find_accel()` masks the modifiers before comparing.** Lock, Num-Lock and the pointer buttons
|
||||||
|
all arrive in `GdkModifierType`, so an unmasked compare matches no table row at all once
|
||||||
|
Caps Lock is on.
|
||||||
|
- **A UI file is data, and a mistake in it is a runtime abort, not a build failure.** `.ui` files live
|
||||||
|
in `src/ui/` and are compiled into the binary by the third GResource (`udpscope-ui.gresource.xml`,
|
||||||
|
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`.
|
||||||
|
|
||||||
|
`preprocess="xml-stripblanks"` catches a *malformed* file at build time -- which is why it is on
|
||||||
|
every file in every bundle -- and that includes an XML rule easy to break by hand: **a comment may
|
||||||
|
not contain a double hyphen**, so prose written the way every C++ comment in this project is written
|
||||||
|
(` -- like this -- `) fails the build with a caret under the offending character. Use an em dash in a
|
||||||
|
UI file.
|
||||||
|
|
||||||
|
What it cannot catch is a file that is valid XML but wrong for GTK, and that is what
|
||||||
|
`cmake --build build --target test-ui` is for: it round-trips every file through
|
||||||
|
`blueprint-compiler`, which type-checks properties and child types against the GIR -- for *every*
|
||||||
|
file, rather than for the windows a probe happens to open (see
|
||||||
|
[build-and-run.md](build-and-run.md)). Without that check, `GtkBuilder` reports such a file with a
|
||||||
|
**`Gtk-ERROR`, which aborts the process** by default, so a window built from bad markup takes the
|
||||||
|
application down rather than failing to appear.
|
||||||
|
|
||||||
|
Two more paid for: a `GIcon` property takes an **object**, not a name
|
||||||
|
(`<property name="icon">foo</property>` is read as a reference to another object and fails with
|
||||||
|
"Object with ID foo not found" -- the icon is a nested `GThemedIcon`); and a `.ui` big enough to
|
||||||
|
exceed 4095 characters needs `-Wno-overlength-strings` on its generated source, because
|
||||||
|
glib-compile-resources writes each file as one C string literal and the warning fires on generated
|
||||||
|
*data*.
|
||||||
|
- **Deprecated widgets may be the right widget, as long as no deprecated *function* is called.** The
|
||||||
|
shortcut overview is a `GtkShortcutsWindow` (`gtk/deprecated/`, deprecated in 4.20) because libadwaita's
|
||||||
|
successor, `AdwShortcutsDialog`, cannot draw a mouse gesture: `AdwShortcutsItem` has no icon property at
|
||||||
|
all, so its shortcut cell can only be an accelerator, and its cell reads "No Shortcut" when there is
|
||||||
|
none. `GtkShortcutsShortcut` has one generic gesture type whose `icon` *is* drawn. Loading the window
|
||||||
|
from a `.ui` and presenting it as a `GtkWindow` names no deprecated function, so the build stays at its
|
||||||
|
four warnings. When such a window closes, its builder is **replaced on the next opening**, not kept: the
|
||||||
|
window belongs to the builder's object list and a destroyed `GtkWindow` cannot be presented again.
|
||||||
|
- **An `AdwDialog` is not a `GtkWindow`, and not every toplevel is a widget either.** The preferences
|
||||||
|
are an `AdwDialog`, which is a `GObject` that a *window* presents, so it cannot parent a
|
||||||
|
`GtkFileDialog`: the form keeps the window it was opened with (`PrefsForm::parent`, the caller's) and
|
||||||
|
hands that to the picker. Two things follow, and each cost a run. In its floating presentation mode an
|
||||||
|
`AdwDialog` adds a **native surface** to `gtk_window_get_toplevels()`, so that list holds things that
|
||||||
|
are neither `GtkWindow`s nor widgets -- `GTK_WINDOW()` on one is a segfault inside `gtk_window_close()`,
|
||||||
|
and `g_object_unref()` on one is a `GLib-GObject-CRITICAL`. Anything walking it must test
|
||||||
|
`GTK_IS_WIDGET()` *and* `GTK_IS_WINDOW()` first, and a probe should not walk it at all where a
|
||||||
|
cancellable will do.
|
||||||
|
- **A folder chooser outlives the dialog that opened it unless something cancels it.** The picker's
|
||||||
|
answer writes a row, and the row is gone once the dialog closes. `PrefsForm::browse_cancel` is
|
||||||
|
cancelled by the `closed` handler and replaced by the next chooser: one open picker at a time, and none
|
||||||
|
that can call back into a cleared form. It is also how a probe takes the cancel path without a mouse.
|
||||||
|
- **An `AdwApplicationWindow` refuses a titlebar, and refuses it by *aborting*.** `gtk_window_set_titlebar()`
|
||||||
|
on one is a fatal `Adwaita-ERROR` (`is not supported for AdwApplicationWindow`), not a warning that
|
||||||
|
leaves the window merely ugly. The main window is therefore a `GtkApplicationWindow` -- an Adw window
|
||||||
|
would want its header as an `AdwToolbarView`'s top bar instead -- and `build_toolbar()`'s `GtkHeaderBar`
|
||||||
|
stays the window's own titlebar, which is also what gives the window its controls: a `GtkHeaderBar`
|
||||||
|
draws them only as a titlebar.
|
||||||
|
- **libadwaita's rows do not share one property set, and a `.ui` is where that shows.** An
|
||||||
|
`AdwEntryRow` is an `AdwPreferencesRow`: it has a `title` and **no `subtitle`**. `AdwSpinRow` and
|
||||||
|
`AdwSwitchRow` are `AdwActionRow`s and have both. A hint about an entry -- "empty = unicast" --
|
||||||
|
therefore belongs in the *group's* `description`, which is the better place for it anyway: a
|
||||||
|
placeholder vanishes as soon as there is a value, and a description does not. And an `AdwDialog` has
|
||||||
|
**no `content` property**: its child is a plain `<child>`, where an `AdwToolbarView` does take
|
||||||
|
`content`. Both mistakes are a `Gtk-ERROR` at load, so both abort the process, and both are caught by
|
||||||
|
the probe that opens every window rather than by anything at build time.
|
||||||
|
- **A dialog reachable from two windows cannot own its state in either.** The preferences open from
|
||||||
|
the main window's menu *and* from the source chooser, which exists before `App` does (`main()`
|
||||||
|
chooses a source, then builds the model and the UI). The widgets therefore live in a `PrefsForm` the
|
||||||
|
caller owns — `App::prefs`, `Chooser::prefs` — and what a change *means* is a callback, because it
|
||||||
|
means different things on the two sides. Both are plain members of their owners, on the same stack
|
||||||
|
frame, so nothing can outlive the frame that built it.
|
||||||
|
- **A transient window is destroyed before its parent.** `chooser_finish()` destroys the preferences
|
||||||
|
window before the chooser, and nulls the pointer *before* the destroy so `close-request` cannot
|
||||||
|
re-enter a dead window. GTK does not promise to take a transient down with its parent, and a window
|
||||||
|
whose handlers point into a returned stack frame is how this project already earned one
|
||||||
|
use-after-free.
|
||||||
|
|
||||||
|
- **A `_` in a label is a mnemonic only on a widget that was asked for one.** GTK reads the underscore as
|
||||||
|
"the next letter is this widget's accelerator, and draw it underlined", so wherever the widget has not
|
||||||
|
been asked, GTK draws the underscore *literally* -- which is what `_Directory`, `_Server`, `Keep _events`
|
||||||
|
and eleven more titles did in the two preference forms, for as long as they existed. Two different
|
||||||
|
switches, FALSE by default in both cases: `GtkLabel`/`GtkButton` take `use-underline`, and the rows take
|
||||||
|
**`AdwPreferencesRow:use-underline`** ("whether an embedded underline in the title indicates a mnemonic").
|
||||||
|
The row classes that carry it are not all related -- `AdwEntryRow` has its own, while
|
||||||
|
`AdwSpinRow`/`AdwComboRow`/`AdwSwitchRow` get it through `AdwActionRow` -- so `row_underlines()` asks by
|
||||||
|
name on a `GObject` (`g_object_class_find_property`, then `g_object_get`) instead of casting to one row
|
||||||
|
type: casting a row whose type is only forward-declared in that translation unit does not even compile.
|
||||||
|
`test-ui` is what catches the property being set on a class that does not have it -- it validates every
|
||||||
|
property against the GIR, inherited ones included.
|
||||||
|
- **A mnemonic is unique per window, and the first match wins.** `_Cancel` and `_Connect` both claimed C in
|
||||||
|
one form, so `Alt+C` could only ever reach one of them and the other's marker was unreachable -- worse
|
||||||
|
than no mnemonic, because the underscore is there in the source for a reader to trust. `Conn_ect` freed
|
||||||
|
it. Rows on different pages of a preferences dialog may share a letter: an unshown page cannot be
|
||||||
|
activated.
|
||||||
|
|
||||||
|
- **A `GtkExpander`'s child is not in the widget-parent chain.** `gtk_widget_get_parent()` on the widget an
|
||||||
|
expander holds answers NULL, before *and* after the window is presented, while `gtk_expander_get_child()`
|
||||||
|
returns it -- measured all three ways (the `.ui`'s `child` property, a code `gtk_expander_set_child()`, a
|
||||||
|
code-made expander). So a probe that walks parents to prove a widget is inside a panel reads 0 for
|
||||||
|
everything behind an expander, on a panel that is entirely correct; the walk has to start at
|
||||||
|
`gtk_expander_get_child()`. That mistake cost a false regression, a "the property does not attach it"
|
||||||
|
conclusion drawn from it, and a pointless second root in the file before the throwaway program settled it.
|
||||||
|
Related: `gtk_expander_set_child()` asserts that its child has no parent yet, so a block the `.ui` has
|
||||||
|
already put in the panel cannot then be handed to an expander -- the file's `child` property is the right
|
||||||
|
way, and it works.
|
||||||
|
|
||||||
|
- **`--` is not allowed inside an XML comment, and a `.ui` comment is XML.** `<!-- ... -- ... -->` is a parse
|
||||||
|
error: `xml-stripblanks` fails the build, and `make test-ui` prints `FAIL <file>` with **no** message under
|
||||||
|
it, because blueprint-compiler's decompiler aborts on the malformed XML before it can report anything --
|
||||||
|
the bare `FAIL` line is that mistake and nothing else. The house style for a dash in prose is the em dash,
|
||||||
|
which is why no other `.ui` in `src/ui/` contains a double hyphen. It has cost two slices a debugging
|
||||||
|
round for one character.
|
||||||
|
|
||||||
|
- **A `.ui` file instantiates its objects once, so anything whose *count* is data cannot live in one.** The
|
||||||
|
layout editor's overlay buttons are one per row, plus one per boundary between them: a `GtkBuilder` would
|
||||||
|
hand back the first and nothing for the rest, and there is no "prototype the code copies" in the format.
|
||||||
|
They are therefore code, and what they share -- the icon, the two CSS classes that make a small round
|
||||||
|
on-screen-display control, and the size -- is one function (`layout_overlay_button()`) rather than three
|
||||||
|
copies. The same reason keeps the math editor's palette and parameter rows in code: their count follows the
|
||||||
|
model, not the file. `.ui` files are for the frame, and the frame is what has no count.
|
||||||
|
|
||||||
|
- **A code-added child of a header bar lands *after* the file's, so going first means using a slot.**
|
||||||
|
`gtk_header_bar_pack_start()` appends to the start group and there is no prepend. The time-window control is
|
||||||
|
code -- its menu's rows come from the buffer's limits -- and belongs at the bar's head, so `header-bar.ui`
|
||||||
|
declares an empty `window_control_slot` box as its first start child and the code appends the control into
|
||||||
|
it. Packing the control straight onto the bar would have put it to the right of everything the file packed,
|
||||||
|
and `[udpscope] header:` (`window_button_w=130 bar_w=756`) is the reading that shows such a move.
|
||||||
|
- **`<child type="end">` is added leftwards-from-the-right, in document order.** A header bar's end group
|
||||||
|
reads the *reverse* of the file: `header-bar.ui` declares menu, sidebar, math, measures, cursors and the
|
||||||
|
screen shows cursors, measures, math, sidebar, menu. The three comments this replaced each had it wrong
|
||||||
|
once, which is why the order is the file's now and a byte-for-byte diff of the probe is the check.
|
||||||
|
|
||||||
|
- **A widget can end up in the wrong container, and only a *structural* read notices.** `math_body` was
|
||||||
|
written as a `<child>` of the *window* instead of the box above it when the math editor's frame moved into a
|
||||||
|
`.ui`. A `GtkWindow` takes exactly one default `<child>`, so nothing failed: the body -- with the canvas and
|
||||||
|
the parameter form inside it -- was left parentless, `visible` but never `mapped`, size `0x0`, drawing
|
||||||
|
nothing. The symptom was "the editor opens and the canvas is blank", with the window sized around its
|
||||||
|
remaining children. `check_ui` passes such a file; `xml-stripblanks` passes it; so does every probe that
|
||||||
|
reads *values* off the widgets inside. What catches it is a reading that walks *up* from the widget
|
||||||
|
(`in_dialog=1`) or asks whether it was ever given an allocation (`mapped=1`, `canvas=420x836`). When a frame
|
||||||
|
moves into a file, the probe that proves it has to read the frame's *structure*, not only the numbers it
|
||||||
|
holds.
|
||||||
|
|
||||||
|
- **A popover's autohide is only as good as its grab.** `GtkPopover` dismisses itself when it loses the
|
||||||
|
pointer grab -- and the grab is the thing that can be missing: a window manager moving it, a nested popover
|
||||||
|
(a `GtkDropDown`'s menu, which the time-window row has) taking it, and *every* headless run, where there is
|
||||||
|
no manager to hand one out and the probes have to turn autohide off before a popover will stay up to be
|
||||||
|
measured. The symptom is "sometimes it is awkward to close": a click anywhere else leaves the bubble up.
|
||||||
|
`on_window_popover_press()` states the rule outright instead -- any press that reaches the window's own
|
||||||
|
surface closes the window popover. That needs no hit test beyond the button, because a popover *is* its own
|
||||||
|
surface and a click inside it never arrives; the button is left to its own click, whose toggle is what
|
||||||
|
closes it. `[windowpop]` reads both halves, with autohide off so it is testing the rule and not the grab.
|
||||||
|
|
||||||
|
- **A notice shares the status line with the glance, so it has to *look* like a message.** The status bar
|
||||||
|
renders `app.notice + " | " + glance` in one label, which is exactly where a reader skimming the
|
||||||
|
routine text ("live · 9.97 kS/s · 2 s window") will not see it: a refused drop that said "plot 1 holds four
|
||||||
|
signals already" was read as no feedback at all. While a notice is fresh the label carries the stock
|
||||||
|
`.warning` class, so a message looks like a message and the class goes when the notice does. Two corollaries
|
||||||
|
worth knowing: the line composes on its own cadence, so a notice can appear a beat after it was set (do not
|
||||||
|
read the label in the same tick a handler ran), and a refusal should say what to *do* about it -- "drop it on
|
||||||
|
another plot, or add a row with the layout editor (L)" rather than the bare statement of the limit.
|
||||||
|
|
||||||
|
- **The status line cannot cover the gap before the window exists, so a window does.** Between the chooser
|
||||||
|
closing and `gtk_window_present()` there is no main loop at all: the chooser destroyed its own window,
|
||||||
|
then `main()` allocates the sample buffer, waits for the first CONFIG or reads a recording, and
|
||||||
|
`on_activate()` builds every panel -- at a few gigabytes of buffer that is the ten seconds a user reported
|
||||||
|
as "the dialog disappeared and nothing happened". `loading_show()`/`loading_hide()` (in
|
||||||
|
`source_dialog.cpp`, beside the chooser they continue) keep an `AdwWindow` on screen across it, raised in
|
||||||
|
`main()` **before** the model is constructed and taken down in `on_activate()` **after** the present --
|
||||||
|
either side is a frame of empty screen. Three rules it exists under:
|
||||||
|
- **It must be painted before the caller blocks.** `gtk_window_present()` only queues the frame, and
|
||||||
|
nothing after it runs a loop, so `loading_show()` ends in a nested `GMainLoop` quit by a timeout
|
||||||
|
(the chooser's own idiom). Iterating pending events is *not* enough: a window needs a frame-clock
|
||||||
|
cycle, and the frame clock only ticks while a loop runs.
|
||||||
|
- **It says what the wait is, in words.** The spinner is frozen for the whole of it -- no loop -- and a
|
||||||
|
progress bar would be a lie for the same reason, so the text carries the information: the address
|
||||||
|
being connected to or the file being opened, and under it the buffer size, which is the number that
|
||||||
|
explains the duration. It is `deletable=0` with no title buttons: a close button that cannot be
|
||||||
|
serviced until the work is over is worse than none.
|
||||||
|
- **Only the path that *asked* gets one.** `main()` raises it only when the chooser actually ran, which
|
||||||
|
is why every documented probe (all of them pass `--server`) is on exactly the path it was before --
|
||||||
|
and a `--server` run never had a window to lose, so the post-present notice is the right place for it.
|
||||||
|
`loading_hide()` is a no-op when none is up, so `run_ui()` needs no notion of which path it is on.
|
||||||
|
|
||||||
|
## The help affordance layer
|
||||||
|
|
||||||
|
The `Help` submenu, the contextual help's "?" and the guided tour, and the traps each of them met.
|
||||||
|
|
||||||
|
- **`gtk_widget_measure()` includes the widget's own margins.** Measured: a box whose natural width
|
||||||
|
is 280 and whose `margin-start` is 453 reports `natural = 733`. That matters wherever a measure is
|
||||||
|
used to *place* the widget and the margin *is* the placement: the width the widget was placed at
|
||||||
|
comes back as its width, and the card of the guided tour walked between the bottom-right corner and
|
||||||
|
the left of the target from frame to frame because of it (a `place_natural()` that set no size
|
||||||
|
request did not help -- the margin alone was the loop). Take the margins off a measure that feeds a
|
||||||
|
position back. `hdl`/`bd`-style CSS padding is *not* part of it, and neither is the other axis's
|
||||||
|
margin, so it is `margin-start + margin-end` on the horizontal and top/bottom on the vertical.
|
||||||
|
- **A widget's allocation is not `gtk_widget_get_width()`, and a measure is never a position.** GTK's
|
||||||
|
`gtk_widget_get_width()`/`get_height()` return the widget's **content** box: `size_allocate`
|
||||||
|
subtracts the CSS border and padding before storing them (measured on the tour card, which carries
|
||||||
|
`border 1px` + `padding 12px`: `get_width()` reads 254 where the rectangle on screen is 280), so a
|
||||||
|
card *placed* from it overhangs its own size and covers the target it is meant to sit beside.
|
||||||
|
`gtk_widget_compute_bounds()` is that rectangle -- border and padding in, margins out -- and is what
|
||||||
|
the probe's geometry and the help's figures already read. And `gtk_widget_measure()` cannot stand in
|
||||||
|
for it: its size includes the widget's margins (the bullet above), and asking for a **height with a
|
||||||
|
width** (`for_size`) on a widget whose margins were then subtracted from that width makes GTK warn
|
||||||
|
`Trying to measure GtkBox ... for width of N, but it needs at least M` **once per frame**, because
|
||||||
|
the for_size lands one margin short of the minimum the widget answers with. The guided tour's card
|
||||||
|
therefore takes its size from `compute_bounds()` once a frame has run and falls back to a measure
|
||||||
|
**with no for_size at all** (`-1`, the form GTK measures without that check) only on a frame with
|
||||||
|
nothing allocated yet. The warning reaches no probe -- GTK is built with **structured logging**, so
|
||||||
|
it goes through the log *writer* and never `g_log_set_handler()`/`g_log_set_default_handler()` --
|
||||||
|
which is why the exit dump counts GTK's own warnings through a writer function (`gtk warnings:`,
|
||||||
|
which must read 0) as the one reading that can assert the class away.
|
||||||
|
- **The overflow menu's `Help` submenu is a two-page `GtkStack`, and neither of the obvious
|
||||||
|
alternatives works.** Rebuilding the rows -- the plot menu's own shape -- destroys
|
||||||
|
`app.layout_toggle`, which points into the main view and which `sync_layout_editor()` and the `L`
|
||||||
|
accel read; swapping the *popover's child* instead leaves whichever view is not showing with the
|
||||||
|
popover as its only owner, so it is finalized the moment it is swapped out. The popover's child is a
|
||||||
|
stack, the visible child is the view, and `gtk_popover_present()` re-measures a popover that is
|
||||||
|
already up. `UDPSCOPE_PROBE_MENUS_MS` walks the *visible page*, which is the one line the stack cost
|
||||||
|
the probe.
|
||||||
|
- **A "(i)" joins its surface's row as a sibling, not as an overlay child.** An overlay child
|
||||||
|
sits *on* whatever is under it -- and every panel heading already has a switch or a pencil in a
|
||||||
|
corner. `src/ui_context_help.cpp` is a table of (id, title, body, shortcut, host) where the host is
|
||||||
|
the *row* -- a panel's heading usually, and the row of a specific control (the "record events"
|
||||||
|
switch, the pre-trigger share, the search block's expander *header*) where that is the thing being
|
||||||
|
explained. The one exception is the
|
||||||
|
plot, which has no heading: its "(i)" is an overlay child of the overlay the layout editor already
|
||||||
|
uses, and its corner is free. The icon is **`dialog-information-symbolic`** -- `help-about-symbolic`
|
||||||
|
is *already* the status bar's information button, and two "help" glyphs meaning different things on
|
||||||
|
the same line is the trap the seven app icons exist to avoid; the dump reads the name back through
|
||||||
|
the theme for the same reason the pane [edit] buttons do. `math_head` exists only while the math
|
||||||
|
editor is open, so that row is skipped when it is null.
|
||||||
|
- **A `GtkMenuButton` ignores both `min-width` and a size request, and the icon is a node *below*
|
||||||
|
its `button` subnode.** The "(i)" is meant to be a *mark*, not a button, and the default is 34x34 (a
|
||||||
|
24 px floor plus 5 px of padding from Adwaita's `button` rule). Measured on libadwaita 1.10: setting
|
||||||
|
`min-width`/`min-height` on the widget changed nothing, and neither did `gtk_widget_set_size_request()`
|
||||||
|
-- the menu button's own measure wins. The node tree is `menubutton > button > box > image` (read off
|
||||||
|
the CSS node dump), so `install_css()` reaches the glyph as
|
||||||
|
`menubutton.udpscope-hint > button > box > image`. The direct-grandchild form `> button > image`
|
||||||
|
matches **nothing** and is silent about it -- the glyph keeps its natural 16 px, which is why an
|
||||||
|
earlier version shipped a 16x16 mark and took that for the floor it was not. Zeroing the outer node
|
||||||
|
and the `button` subnode and asking the glyph for **12 px** (`min-width`/`min-height` *and*
|
||||||
|
`-gtk-icon-size`, the latter what actually *scales* the drawing rather than clamping its box) gives
|
||||||
|
**12x12**. (A class on the outer node alone leaves the subnode at 34; a class on the subnode alone
|
||||||
|
shrank the width to 16 and left the height at 26.) There is no lower floor: the symbolic glyph renders
|
||||||
|
at any request (measured down to 4 px), so 12 px is a legibility choice and not a limit. The dump's
|
||||||
|
`hint=` reading is that number, measured with `gtk_widget_compute_bounds()` on the first *visible*
|
||||||
|
hint -- a hint inside a collapsed `GtkExpander`'s *child* is not in the widget-parent chain at all and
|
||||||
|
reads 0x0, which would look like a mark shrunk to nothing. That is why the search block's hint rides
|
||||||
|
the expander's **header** (a `label-widget` box, `search_head` in `src/ui/trigger-panel.ui`, which
|
||||||
|
*is* in the tree) rather than its child, and why the `[helpui] collapsed search:` reading asserts the
|
||||||
|
header hint is mapped and 12x12 while the block is collapsed.
|
||||||
|
- **Off is gone, not greyed, and the buttons are found by a data key rather than kept in a list.** The
|
||||||
|
list is what the math editor cannot survive: its heading lives in a builder that is dropped and
|
||||||
|
rebuilt on every opening, so a stored pointer into it would go stale one dialog later. A key on the
|
||||||
|
button (`udpscope-help-hint`, carrying the surface's id) is both the mark that says "this is a hint"
|
||||||
|
and the name the probe and the dump report it by, and removing a hint is `gtk_widget_unparent()`,
|
||||||
|
which means the same thing for a box and for the plot's overlay. The button itself is
|
||||||
|
`ui_impl::hint_button()` reached either by the table's `context_help_sync()` or by
|
||||||
|
`context_help_button()` for a window with no `App` (the source chooser) -- one button, two callers,
|
||||||
|
so the two windows cannot drift into two affordances.
|
||||||
|
- **A hint on a slide-over page's heading is attached all the time and drawn only while the page is open.**
|
||||||
|
The trigger's, signals', cursors' and measurements' headings (`trigger_head`, `signals_head`,
|
||||||
|
`cursors_head`, `measures_head`) live on pages that exist, unmapped, whether or not they are open, so
|
||||||
|
`context_help_sync()` attaches their "(i)" like any other and the count (`hints=12`) does not depend on
|
||||||
|
which page is up. There is **no second code path** and the dock's readouts carry no hint of their own: the
|
||||||
|
pages' hints say what the dock shows too (the cursors' and measurements' bodies mention the read-only copy
|
||||||
|
and the Edit button). A probe that must *see* one drawn needs its page open, like every other reading off a page.
|
||||||
|
- **The tour has one stop for the readouts and none for the pages behind them.** The dock's stop
|
||||||
|
(`side`, target `side_box`, no `page`, so it closes the panel) describes the readouts, the **Edit**
|
||||||
|
buttons and the click on a measurement; the `cursors` and `measure` pages are reached from there rather than
|
||||||
|
walked. A stop for either would be one more `TourStop::page` (see the next bullet), and the plan's
|
||||||
|
length is asserted (`steps=6`, `steps=5` with the dock put away) -- a new stop changes both.
|
||||||
|
- **The guided tour takes no grab, and that is the whole reason it is an overlay.** Four dim
|
||||||
|
rectangles around the hole are ordinary widgets that swallow the clicks that land on them and
|
||||||
|
nothing else; `Skip`, `Finish` and `Escape` hide them, and there is no popover, no gesture and no
|
||||||
|
grab to leak. The probe asserts it as a reading -- `controllers=` against the count taken before the
|
||||||
|
tour opened -- rather than as a claim, because "no lingering grab" is exactly the sort of thing a
|
||||||
|
later change can add back without noticing.
|
||||||
|
- **Click-away coexists with the plot's gestures because the observer never claims the sequence.** The
|
||||||
|
slide-over closes on a press anywhere outside it, and the plot underneath already owns pan, zoom, drop and
|
||||||
|
right-click. A `GtkGestureClick` added to `plot_overlay` in `GTK_PHASE_CAPTURE` sees the press before the
|
||||||
|
plot's own controllers and **does not call `gtk_gesture_set_state()`**, so the sequence stays unclaimed and
|
||||||
|
the plot's gestures run exactly as before; claiming it (or putting a full-area target over the plot) would
|
||||||
|
swallow the very pan the click was part of. The observer decides inside/outside by
|
||||||
|
`gtk_widget_compute_point()` into the revealer plus `gtk_widget_contains()`, never by pick. The press
|
||||||
|
that closes the panel still reaches the plot, which is accepted: it is one click and not a drag the user
|
||||||
|
started on purpose. `[slideover]` reads `click_inside_closes=0 click_outside_closes=1`.
|
||||||
|
- **`AdwToggleGroup` is the segmented switch** (libadwaita >= 1.7; the build asks for 1.8). It replaces the
|
||||||
|
`AdwViewSwitcher` for a source with several panels (the E-1608: Analog out | Digital I/O). Its toggles are
|
||||||
|
named by the slide-over page ids, and the group's `notify::active-name` is the *input*; writing it
|
||||||
|
ourselves (`slideover_open` on a source page) goes through `SlideOver::syncing`, or the program's own
|
||||||
|
write comes back as a click and re-opens the page it just opened. It is hidden unless a source page is
|
||||||
|
open and there are two or more, which `[badges] switch_visible=` reads.
|
||||||
|
- **The tour is one implementation over two windows.** `ui_impl::TourView` holds the overlay children
|
||||||
|
and the walk's place in it (`tour_build`/`tour_begin`/`tour_advance`/`tour_end`/`tour_update`), and
|
||||||
|
what differs between the windows is only the *stops*: a `TourStop` names a widget the owner resolves,
|
||||||
|
so each window keeps its own table -- the main window's against `App` fields,
|
||||||
|
`source_dialog.cpp`'s `kChooserStops` against the `Chooser`'s. A walk that passes its last stop calls
|
||||||
|
the view's `on_done`; the window's passes null, the chooser's sets `Config::tour_pending`, which is
|
||||||
|
how the two halves are one tour. `App` carries its own `TourView` (`app.tour`), so the machinery is
|
||||||
|
compiled once and the probe reads either window's state by the same fields.
|
||||||
|
- **A stop declares the page it stands on, and the tour selects that page as the stop is entered --
|
||||||
|
by a hook in the main window and by a stack in the chooser.** The main window has no page stack any
|
||||||
|
more: its pages are the slide-over's. A `TourStop::page` there is a slide-over page id (`trigger`), and
|
||||||
|
`TourView::on_page(page_user, page)` opens it -- or, for a stop whose `page` is null, **closes** the
|
||||||
|
panel, so the card never points at the status line over an open panel. `tour_end()` calls
|
||||||
|
`on_page(nullptr)` *only if a walk was running* (so Skip, Esc and the last Next close a panel the tour
|
||||||
|
opened, while dismissing an idle tour does not close one the user opened). The chooser has no hook and
|
||||||
|
keeps `TourView::page_stack` (an `AdwViewStack`, selected by name). `select_view_page()` is the single
|
||||||
|
entry for both, and `show_step()` calls it before anything is measured.
|
||||||
|
Two rules follow from a panel that *slides*. The page switch is **not** followed by a one-shot measure:
|
||||||
|
`tour_update()`, which already runs every frame, fills the spotlight in on a later one, and for a stop
|
||||||
|
with a page it waits for `TourView::page_settled` (the slide-over's `gtk_revealer_get_child_revealed()`)
|
||||||
|
-- until then the target is moving, so the surface is dimmed with an empty hole. And **the hole is the
|
||||||
|
target's bounds clipped by every `GtkScrolledWindow` above it** (`tour_target_bounds()`): a form taller
|
||||||
|
than the panel's scroller overruns it, and an unclipped hole spotlights the badge bar and the status
|
||||||
|
line under the panel. A target scrolled out entirely is a *measured, empty* rectangle (the function
|
||||||
|
returns true), not a failure -- `tour_update()` zeroes the hole on it, because taking "false" for "not
|
||||||
|
measured yet" keeps the previous frame's hole standing. A stack allocates a new page's widgets on the
|
||||||
|
frame after the switch, so a measurement taken in the switch's own tick reads zero (the allocation
|
||||||
|
bullet in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)) -- reading the
|
||||||
|
page *name* back is fine immediately, because that is state and not geometry.
|
||||||
|
- **The stop filter runs on `get_mapped()`, not `is_visible()`, and that is what separates a wrong
|
||||||
|
page from a collapsed pane.** GTK 4.24's `gtk_widget_is_visible()` walks only the widgets' own
|
||||||
|
`visible` flags up the parents; it does **not** consult `child_visible`. Both a `GtkStack`'s
|
||||||
|
non-current pages and `AdwOverlaySplitView`'s hidden sidebar are hidden with
|
||||||
|
`gtk_widget_set_child_visible()`, so a widget on another page, *and* one in a collapsed pane, both
|
||||||
|
read `is_visible() == TRUE` (verified against 4.24.1) -- the old filter kept a stop it meant to
|
||||||
|
drop and left the spotlight on a target with no allocation. `stop_is_walkable()` keeps a stop when
|
||||||
|
its target is **mapped**; a stop that names a page of a view with an `on_page` hook is **always**
|
||||||
|
kept (the hook opens the page whatever the dock is doing, since the slide-over does not depend on it);
|
||||||
|
and in a view with a `page_stack` a stop that names a page is kept when the stack is mapped -- a wrong
|
||||||
|
page leaves the stack mapped, so the page switch reveals it, while a collapsed pane leaves it unmapped
|
||||||
|
too and the stop is dropped. So the dock's stop is dropped in a window narrow enough to overlay it, and
|
||||||
|
the main window's tour is five steps there (`[toursurface] collapsed: ... steps=5`: the signals stop names
|
||||||
|
the `signals` slide-over page, which does not depend on the dock either). The filter
|
||||||
|
lives in `tour_begin()`, not in each window's `start_tour()`, so the two windows cannot drift
|
||||||
|
into two rules.
|
||||||
|
- **The chooser's tour needs an overlay over its *whole* window, and `AdwWindow`'s content is
|
||||||
|
replaced, not wrapped.** Two of the chooser's stops name header-bar buttons, and an overlay inside
|
||||||
|
the `AdwToolbarView`'s `content` would give them negative coordinates and clamp the hole to the top
|
||||||
|
edge. So the overlay *becomes* the window's content and adopts the toolbar view -- and the order
|
||||||
|
matters: `gtk_overlay_set_child()` refuses a child that still has a parent, so
|
||||||
|
`adw_window_set_content(win, overlay)` goes **first** (that call is what unparents the toolbar view)
|
||||||
|
and `gtk_overlay_set_child(overlay, content)` second. The builder's object list still owns the view,
|
||||||
|
so nothing is finalized in between. Getting the order wrong is a `Gtk-CRITICAL` and an empty
|
||||||
|
chooser, which is exactly what the first version of this did.
|
||||||
|
- **The chooser's hints are its own table because it runs before `App`.** An `AdwEntryRow` is not a
|
||||||
|
box and its editor is internal, so a hint cannot join the host/port fields as a sibling -- appending
|
||||||
|
it to the `AdwPreferencesGroup` would make it a row under the field rather than a mark beside it, so
|
||||||
|
no hint is invented for them and the group's own `description` carries that text. The two rows the
|
||||||
|
dialog *does* have -- the heading above the saved-source list and the waveforms page's action row --
|
||||||
|
carry `chooser_sources` and `chooser_waveform`, added with the same `context_help_button()` the
|
||||||
|
window uses and re-synced by its Help menu's toggle.
|
||||||
|
- **The hole is kept from the last frame that could measure the target.** `gtk_widget_compute_bounds()`
|
||||||
|
answers nothing on a frame that lands between two layout passes, and letting the hole empty there
|
||||||
|
made the spotlight and the card jump to the corner and back; a *target that is not mapped* is the
|
||||||
|
different case (a genuinely absent surface) and is what empties it. The placement is also
|
||||||
|
re-measured on **every frame**, never once at the start: a widget's allocation is 0 on the frame it
|
||||||
|
is first shown, and a window resize moves it under a one-shot measurement.
|
||||||
|
- **The first-run tour is a `settings.toml` key (`tour_seen`), written when the tour *starts*.** Not
|
||||||
|
"the settings file did not exist": a user who has already changed the theme has a file and has still
|
||||||
|
never seen the tour. Written on the way *in*, because a user who skips it in the first second has
|
||||||
|
still seen it. It starts **in the source chooser** when one is shown (the chooser is the surface a
|
||||||
|
first run meets first, and the window's own auto-start is unreachable until the chooser has returned
|
||||||
|
with a source) and in the window otherwise; either way it is a 1.5 s timer, so the window is up and
|
||||||
|
laid out before anything points at it. The chooser's walk writes the key itself and hands the second
|
||||||
|
half over by `Config::tour_pending`, so a first run is *one* tour that crosses the two windows.
|
||||||
|
- **The `help figures:` line is captured on the tick, like every other size the dump prints.** It names
|
||||||
|
ten surfaces' window-coordinate rectangles for the help's annotated figures, and the dump runs after
|
||||||
|
the window is destroyed -- a `gtk_widget_compute_bounds()` there is the "no `gtk_widget_*` call in the
|
||||||
|
dump" rule again. `is_visible()` *and* `get_mapped()`, not `get_visible()`: a widget under a hidden parent -- the trigger
|
||||||
|
summary while the pane is in configuration mode -- keeps its own flag true and reads 0x0 (and a widget on a closed slide-over page, `trigger_*`
|
||||||
|
while the panel is shut, would read a bogus `630,46,0,0` without the mapped test), and a crop
|
||||||
|
box drawn from that is a box over the wrong thing. Its numbers depend on the *window's* size, which
|
||||||
|
under the documented `xvfb-run` is the window's 756x894 minimum (a 640x480 screen), so a run on a
|
||||||
|
larger screen gives different rectangles for the same panels.
|
||||||
|
|
||||||
|
- **GTK4 has no `GtkWidget::destroy` signal; the lifecycle hook is `close-request`, and
|
||||||
|
`gtk_window_destroy()` never emits it.** A `g_signal_connect(window, "destroy", ...)` on a GTK4
|
||||||
|
widget is a connect to a signal that does not exist: it never fires (and its "signal not found"
|
||||||
|
warning is in the GObject domain, not GTK's, so the dump's `gtk warnings:` counter cannot see
|
||||||
|
it). That is why the dialogs use `gtk_window_close()` when their *lifetime* matters (a close
|
||||||
|
clears `App::measure_dialog`, which a destroy would not), and why the main window's own handler
|
||||||
|
-- the scope windows' teardown, the debounced layout-save flush and the application quit -- is
|
||||||
|
connected to **`close-request`** and returns `FALSE` so the default destroy still runs.
|
||||||
|
Everything that ends the main window programmatically (the exit probe's `exit_probe_step3b`)
|
||||||
|
closes it with `gtk_window_close()` for the same reason. The **scope windows go with it**:
|
||||||
|
each is a `GtkWindow` added to the application, and one left behind keeps the GApplication's
|
||||||
|
main loop alive around a window that is gone -- the hang where the exit run never reached its
|
||||||
|
dump.
|
||||||
|
|
||||||
|
- **A page's name lives in the slide-over header, once.** The header prints the open page's title
|
||||||
|
(from the `GtkStackPage`) beside its **Close** button; a page body that repeats the name is the
|
||||||
|
same word twice, and a body-level "back to the list" button duplicates a navigation the header
|
||||||
|
already owns. Signal pages therefore carry **no** name label and **no** back button
|
||||||
|
(`[signalpage] no_dup=1`), and their title is the **numbered** name -- `<channel number>. <name>`,
|
||||||
|
the same `measure_channel_number()` the badges and the measurements pane use -- so the panel says
|
||||||
|
*which acquired channel* this is rather than only what it is called. `slideover_set_title()`
|
||||||
|
retitles a page in place (the open one included) because that number changes when the stream
|
||||||
|
renumbers its channels; `fill()` sets it on every fill, and a page added by
|
||||||
|
`slideover_add_page()` gets its final title from the `fill()` that follows.
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
# 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 three graphs -- the third a `Source` of the first channel into a
|
||||||
|
`DeNoise` node, so the editor's median row is built and synced on a baseline run -- 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.
|
||||||
|
- **A file view has no producer, so a structural change would leave it empty.** `set_math_channels()`
|
||||||
|
rebuilds the channels *and* the buffer, and a rebuild drops the history: right for a stream, whose producer
|
||||||
|
refills it from the next packet, and wrong for a file, which has nothing to refill it with. Adding a math
|
||||||
|
channel to an open waveform therefore emptied *every* channel -- `samples=0` on the sources too, which a user
|
||||||
|
reads as "the plot is broken" -- and the same emptiness follows any structural change in a file view.
|
||||||
|
`reload_file_view()` (in `ui.cpp`) re-reads the file after such a change, which is also what computes the
|
||||||
|
derived channels over it: the load's own pushes run the math once the set is in place. It fires only when the
|
||||||
|
buffer was *really* dropped -- `stored_slots() > 0` means the change found nothing to do -- and it says so
|
||||||
|
on stderr (`reloaded <path> after a structural change`), because a second read of a file is worth
|
||||||
|
accounting for. `set_math_channels()` in turn treats an identical list as no change at all, so a no-op
|
||||||
|
application (the session apply, a re-open) neither rebuilds nor drops nor re-reads; the comparison leaves the
|
||||||
|
nodes' `x`/`y` out on purpose, since those are the canvas's own and no op reads them -- a graph that only
|
||||||
|
*moved* is not a change, and rebuilding for it would drop the buffer to store two numbers. It is why `--open`
|
||||||
|
works at all with a session that defines math channels, since `main()` loads the file *before* the UI (and
|
||||||
|
so before the session's graphs) exists.
|
||||||
|
- **Every path that fills a store must skip a derived channel, not just the packet path.**
|
||||||
|
`ChannelInfo::math` states the rule for the ingest; `push_loaded_slot()` broke it, so loading a file pushed
|
||||||
|
its own value into each derived ring and `push_math_sample()` then pushed the computed one: two samples per
|
||||||
|
slot, i.e. a trace at double rate. The reading that catches it is the one already in this file --
|
||||||
|
`math '<name>': N samples vs M time slots` must be **equal** -- and a file view now has to be read as well
|
||||||
|
as a live one (`--open <the file>` with `UDPSCOPE_PROBE_MATH_MS`). An export writes every *plotted* channel,
|
||||||
|
so a file carries the derived ones as ordinary columns: those are real channels and are filled normally,
|
||||||
|
which is why the fix is in the push and not in the loader.
|
||||||
|
`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 three-graph run reads **103**, the
|
||||||
|
de-noise channel's per-sample median being the difference.
|
||||||
|
The probe also asserts the new editor mechanics. `dialog build:` reads the identity row back as well --
|
||||||
|
`name='math3' unit=''` -- which is the pair the `.ui` holds the widgets for and the code fills from the
|
||||||
|
channel being edited, so a row that stopped being the one the code writes shows there.
|
||||||
|
`editor frame:` is the same idea one level down: it reads the canvas's allocation, whether that canvas is
|
||||||
|
*mapped*, and whether the canvas and the name entry are inside the dialog at all -- delayed by 500 ms, because
|
||||||
|
an allocation only exists after a layout. A canvas that is visible but parentless never maps and stays `0x0`,
|
||||||
|
and reading values off the widgets *inside* the editor cannot see that. It is the reading that catches
|
||||||
|
`math_body` written as a child of the window instead of the box above it. 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.
|
||||||
|
- **`MathOp` is append-only, because `math_op_at()` *is* the serialisation.** A session stores a node's op
|
||||||
|
as its index through that accessor, so an op inserted in the middle reinterprets every node in every file
|
||||||
|
written before it; `DeNoise` is therefore index 12, after `Div`, and sits last in the dropdown.
|
||||||
|
`math_op_uses_filter()` keeps its meaning of *a biquad* -- `math_op_uses_fc()` is the wider question
|
||||||
|
("does this op read `fc` at all") and is what the dialog's corner row asks.
|
||||||
|
- **The de-noise node's median window is *pooled* in `MathRuntime::median`, not held in `MathNodeRt`**, so a
|
||||||
|
node's state stays on one cache line; nine doubles per node would push every node in the graph, filter or
|
||||||
|
not, across two. `MathNodeRt::med_at` / `::med_n` / `::med_pos` / `::med_have` are that pool's slice,
|
||||||
|
length, write position and fill, and the pool is sized in `math_prepare()`.
|
||||||
|
- **Median *then* pole, and the order is the design.** The median kills an impulse outright -- one shorter
|
||||||
|
than half its window cannot move it at all -- and the pole takes the broadband hash it leaves behind. The
|
||||||
|
other order feeds the pole the impulse, which smears it over the pole's whole settling time and leaves the
|
||||||
|
median a wide bump it cannot reject.
|
||||||
|
- **The de-noise pole reuses `b0` and `z1`, deliberately.** `math_prepare()` carries `z1` across a
|
||||||
|
re-prepare, so the pole keeps its history through a decimation change exactly as the biquads do -- while the
|
||||||
|
median window starts from rest, the same choice the biquads' `z2` makes. `median_n()` rounds *up* to odd and
|
||||||
|
caps at 9, and it is what enforces the window rather than the dialog: a session file holding 4 or 40 still
|
||||||
|
loads. The cap is what `double sorted[9]` in the step is sized for.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
## A gap is a gap in a filter's output, never the end of its state
|
||||||
|
|
||||||
|
A reserved hole is NaN in the source channel, and a biquad that integrates it poisons `z1`/`z2`
|
||||||
|
for ever: every later sample comes out NaN, so a filtered trace died at the first drop and never
|
||||||
|
came back. Relative time made it systematic — a counter gap reserves NaN slots by construction,
|
||||||
|
where a stream-time gap is often absorbed by the stamps — but EMI caused it in both modes, and
|
||||||
|
the reading was unambiguous: the memoryless channels held 95% finite samples while every
|
||||||
|
`LowPass`/`HighPass`/`BandPass` channel held `finite=0`.
|
||||||
|
|
||||||
|
`biquad_step()` therefore returns NaN **without touching the state words** when its input is not
|
||||||
|
finite. The hole stays visible in the filtered trace (the gap is the truth about the data), the
|
||||||
|
filter's history remains the last *real* samples it saw, and the trace resumes after the hole
|
||||||
|
with at most the transient a re-start costs. The exit dump's `math '<name>': finite=N/M` line is
|
||||||
|
the assertion: `finite` must be about `M` minus the hole share, and `finite=0` beside non-zero
|
||||||
|
`hole_slots` is this defect returned.
|
||||||
|
|
||||||
|
- **A math channel is a row of the *signal* list, under a `MATH` divider, and there is no math pane.**
|
||||||
|
`append_math_signals_locked()` already puts every channel in `signals()`, so the pane was the same
|
||||||
|
list drawn twice; the divider is what the pane uniquely contributed. It is non-selectable and
|
||||||
|
non-activatable -- a heading that highlighted under the pointer would read as a row you can plot --
|
||||||
|
and it carries the `+` that creates a channel, appended even when there are none, because a `+` that
|
||||||
|
waited for a channel to exist could never create the first one. A math row's menu is *Edit graph…*,
|
||||||
|
*Default colour*, *Delete*: no Information and no Gain/Offset rows, because calibration is applied at
|
||||||
|
ingest and a derived channel has no stream to describe, and no rename, because the node editor owns
|
||||||
|
a channel's name and unit and a second place to change them would be a second answer to the same
|
||||||
|
question. A row's tick is the channel's own switch and must reach `App::math` -- the copy the
|
||||||
|
session file is written from -- or the list and the file disagree about what "enabled" was.
|
||||||
|
- **A graph *edit* does not move the plan; adding or removing a channel does.** The plan is a partition over
|
||||||
|
the channels, so `set_math_channels()` takes a surgical path whenever the *set* is unchanged -- same channels,
|
||||||
|
names and enables: the store is never touched, every other channel keeps its samples **and** its runtimes, and
|
||||||
|
only the edited graphs are prepared again. `math_apply_locked()` computes the whole list on a recompute
|
||||||
|
(the graphs are walked in order, so a channel's inputs must be there) but writes only the channels in the
|
||||||
|
affected mask; the affected set is the edited channels plus whatever *names* them, since one channel's output
|
||||||
|
is another's input. Before this, every edit rebuilt the store and every channel in the window started again,
|
||||||
|
which is the "modifying one expression invalidates all the others" report.
|
||||||
|
`[math-probe] graph edit: stored_slots N -> M` reads it: M must be at least N -- the store keeps filling
|
||||||
|
while the edit runs -- and never 0.
|
||||||
|
|
||||||
|
- **A recompute rewrites a ring *in place*, and both of the ring's counters have to survive it.**
|
||||||
|
`recompute_math_locked()` replays the inputs' **retained** window, which begins at the time ring's
|
||||||
|
`base()` and not at zero, so an emptied derived ring is restarted with `clear_to(base)` and never
|
||||||
|
`clear()`. With `clear()` the refill renumbered the channel from 0 and left its `produced()` short by
|
||||||
|
exactly `base` -- and `live_window_locked()` clamps its window against *every* channel's `produced()`,
|
||||||
|
so that one lagging channel answered `n = 0` for the live view, every pause view **and** every trigger
|
||||||
|
capture, until a whole ring of fresh data had gone by. That is the "a math edit freezes acquisition,
|
||||||
|
and the trigger stops firing on a channel that is not even derived" report, and it is **invisible
|
||||||
|
before the ring first wraps** (`base == 0`, where the two numberings coincide), which is why it reads
|
||||||
|
as intermittent and uptime-dependent. The dump's `math '<name>': N samples vs M time slots` is the
|
||||||
|
reading -- both numbers are `produced()`, so a mismatch *is* this defect -- but only on a run whose
|
||||||
|
ring has wrapped: give it a small `--buffer-samples`. `UDPSCOPE_PROBE_MATHWRAP_MS` is the probe that
|
||||||
|
arranges exactly that, and it exists because **`math_probe` structurally cannot** reach this state:
|
||||||
|
every edit that probe makes follows a structural change, which replans the buffer and restarts every
|
||||||
|
ring at absolute 0, where both halves of this are identities.
|
||||||
|
The published window is the second counter. `acquire()` is incremental, so a rewrite that leaves
|
||||||
|
`produced()` where it was publishes **nothing** and the pyramid -- what the renderer and every
|
||||||
|
measurement read -- goes on describing the samples the edit replaced. `ChannelBuffer::republish_from(base)`
|
||||||
|
is what puts it back, and it takes the ring's base rather than 0 because `invalidate()` would make the
|
||||||
|
next `acquire()` walk every absolute index ever produced (an hour at 1 MS/s is 3.6 G appends) to publish
|
||||||
|
one window.
|
||||||
|
|
||||||
|
- **A math channel whose source is switched off is switched off with it.** `Source` resolves to a *channel*, so
|
||||||
|
a derived channel cannot be computed from a signal nobody acquires: left enabled it would silently compute
|
||||||
|
nothing, which is what "all math signals depending on it do not compute anymore" looked like from outside.
|
||||||
|
`on_signal_switch()` disables the channels that read that signal -- **transitively**, since one math channel
|
||||||
|
may name another -- through the same path the pane's own switch uses (`MathChannel::enabled` and
|
||||||
|
`apply_math_channels()`), and takes their traces off the plots along with the signal's. The channels that do
|
||||||
|
not read it are untouched: `[enable-all-probe]`'s `math_on=[…]` is the half that reads this. Re-enabling the
|
||||||
|
signal does **not** switch them back on, for the same reason re-enabling does not restore the traces.
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
# Invariants: the measurements pane
|
||||||
|
|
||||||
|
Why the pane is refreshed on a cadence, why neither pass may read its window raw, how a crossing is counted, and which mean over a split is which.
|
||||||
|
|
||||||
|
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 need no pass of their
|
||||||
|
own -- `top`/`bottom`/`amplitude` take a second read of that same **bucket** list and never of
|
||||||
|
the window (the flat-level invariant below). **Adding a type must not add a pass** -- that is
|
||||||
|
what `compute_measure()`'s `stats` argument is for.
|
||||||
|
**The window a value is computed over is the *pin* while the view is pinned, and the viewport
|
||||||
|
otherwise.** A pinned view -- a held event, a paused stream, an opened file -- is a *still
|
||||||
|
frame*: the data behind it does not change, so a pan or a zoom only looks closer. Measuring the
|
||||||
|
pin (`RowSet::pinned_range()`, the same accessor `hold_pinned_window()` clamps into, so "what
|
||||||
|
frames this view" has one expression) is what makes a gesture change **no value at all** --
|
||||||
|
and it is decided in `update_measures()`, at the call site, rather than inside
|
||||||
|
`MeasureWindowReader`, because the decision is also *booked*: the window
|
||||||
|
`MeasureWindowReader` is handed is the same one the "it moved" test compares against
|
||||||
|
`App::measure_window`, so a gesture inside the pin spends no recompute either. A reader that
|
||||||
|
chose its own window would leave a pinned view recomputing on every gesture frame for a number
|
||||||
|
that cannot change (457 passes in one run is the shape that mistake took).
|
||||||
|
This test has been wrong in both directions and the reasoning is worth keeping. Suppressing it
|
||||||
|
while held (`moved = ... && !held`) left the pane describing the whole 2 s event
|
||||||
|
(`for=[0.47..2.47]`) while a 50 ms slice of it was on screen; *removing* the exemption was the
|
||||||
|
opposite mistake, and it is the reported "when measuring a still frame (a triggered event, or a
|
||||||
|
paused stream) moving the zoom makes measurements such as average or top change, which should
|
||||||
|
never happen". The two are one question asked of two different windows: **which window is this
|
||||||
|
pane about** (the pin, while pinned) and **has it moved** (asked of that window in every mode).
|
||||||
|
Answers: a pinned view still recomputes when it should -- a fresh latch, a re-stated pin, a new
|
||||||
|
snapshot, the held/live change -- and a settled one satisfies `swin == measure_window` exactly
|
||||||
|
and so reads `moved == false` on its own.
|
||||||
|
`[measurepin-probe]` is the reading: on a latched event zoomed to a quarter of itself,
|
||||||
|
`same=4/4 for_same=1 pass_delta=0` with the values bit-identical and `for=` still at the pin;
|
||||||
|
the same on a pause taken from a held event. Live is the case that must **not** be swallowed,
|
||||||
|
and it reads `same=0/4 for_same=0` with a positive `pass_delta`, `span_before=2.000000`,
|
||||||
|
`span_after=0.125000` and `follows=1` -- the results window narrowed with the viewport and the
|
||||||
|
values moved with it. The
|
||||||
|
one path that is *not* this rule is the window control: `apply_time_window()` unpins
|
||||||
|
(`set_time_window()` clears `pinned_`), so for the frames before the held branch re-states the
|
||||||
|
pin the view really is live-framed and the pane measures the viewport -- `[measure-trace]`
|
||||||
|
prints `pin=off` for exactly those frames, which is what makes them legible rather than looking
|
||||||
|
like the defect again.
|
||||||
|
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 row names its channel: number and signal, from one resolver.** The pane's row reads
|
||||||
|
`N. signal · type`, the dock's and the strip's lines prefix the same number to the type,
|
||||||
|
the signal's badge carries it, and the measurement dialog's signal dropdown numbers its
|
||||||
|
entries with it -- all through `measure_channel_number()`, which is the position of the
|
||||||
|
signal's channel in the model's channel list (the acquired channels in stream order), or 0
|
||||||
|
when the signal is not acquired. The readouts rule cuts both ways here: every view shows the
|
||||||
|
same number because every view asks the one function, and a view that derived a number by
|
||||||
|
its own walk of the channel list would be the second spelling that drifts. 0 is a state, not
|
||||||
|
a failure -- the unacquired signal's row is already grey -- so the readers *omit* the number
|
||||||
|
rather than printing a 0. Renumbering on every enable, disable and CONFIG is inherent (the
|
||||||
|
number answers "which active channel is this", which is what a rebuild renumbers), so a
|
||||||
|
generation change rebuilds the pane as well as the signal list.
|
||||||
|
- **The gate is a per-measurement option, and one band shared by everyone who uses it.**
|
||||||
|
`MeasureOptions::gate` is set per measurement in its dialog; every gated measurement reads
|
||||||
|
only inside the one shaded band -- a plotter `Region`, violet, its two bounds the gate's own
|
||||||
|
pair of instruments, not the cursors, which stay what the cursors pane reads -- and the band
|
||||||
|
is *shown exactly while some measurement is gated*, derived at `rebuild_measures_pane()`,
|
||||||
|
because every change of the measure set (an add, an edit, a delete, a session restore)
|
||||||
|
passes through there. Ungated measurements are keyed away from gated ones
|
||||||
|
(`MeasureWindow`'s key is `(channel, fc, gated)`), so a gated measurement costs one more
|
||||||
|
walk and changes no ungated number.
|
||||||
|
The bounds live on the `RowSet` in **display x** (`gate_x0()`/`gate_x1()`), the cursors' own
|
||||||
|
space, and that is the point: a live axis is newest-relative, so a band at `[-0.3, -0.1]`
|
||||||
|
keeps meaning "300 ms to 100 ms ago" while the data flows under it, and a held event's
|
||||||
|
stamps are edge-relative, so there the band names a fixed slice of the frozen frame. Either
|
||||||
|
way it stays where it was put, which is what makes it a gate and not a second window.
|
||||||
|
`update_measures()` clips the pane's window (viewport live, pin pinned) into the band once,
|
||||||
|
and each gated window walks the clip: every statistic, mark and "it moved" booking below
|
||||||
|
describes the gated slice through that one expression. A band entirely off the view clips to
|
||||||
|
a zero span and the pane reads the same `--` any empty window gives. Because a band that
|
||||||
|
moves can change what the *gated* measurements describe without the viewport or pin moving,
|
||||||
|
the "it moved" test asks the band as well (`measure_gate_window`, booked every pass). While
|
||||||
|
a region drag runs, the tick adopts the bounds out of the dragged row's plot
|
||||||
|
(`sync_gate(drag_row)` -- the `sync_cursors()` contract: read only while a gesture holds the
|
||||||
|
instrument, because outside one the read is our own echo) and mirrors them to every other
|
||||||
|
row; the drag's *end* saves the session, because the bounds are session state
|
||||||
|
(`gate_x0`/`gate_x1`, and each measure's `gate` flag in its `[[measure]]` section) and a
|
||||||
|
drag that is not saved is a session that lies about where the gate is. `[gate-probe]` is the
|
||||||
|
reading: the gated average differs from the ungated one on the same signal, the booked gate
|
||||||
|
window is the band moved onto the stream's clock, the band is still where it was put a
|
||||||
|
second later while `s0=` has moved under it, and a restored session brings both back.
|
||||||
|
- **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. That read as `top=0.614304` on a unit sine against the 2/pi a
|
||||||
|
mean over a half then gave; a *bucketed* window no longer reports `top` from those sums at all
|
||||||
|
(see the flat-level invariant below), which makes the rule easier to break silently rather than
|
||||||
|
less binding -- the sums are the fallback a window with no histogram reads, so a wrong one is a
|
||||||
|
wrong answer waiting for that window. 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 it and `average` still come straight out of the one shared
|
||||||
|
`measure_stats()` pass. A bucket's sum is a sum. (`top` has no *exact* reading to protect any
|
||||||
|
more: it is a density estimate and not a mean, so the assertion that used to be
|
||||||
|
`top == 0.636620` on a sine now belongs to `above 50`, which *is* a mean over a side and reads
|
||||||
|
exactly 2/pi again -- see the band-average invariant below, and the flat-level one for what
|
||||||
|
`top` reads instead. Everything above is about the *bucket* path being a fast path and not an
|
||||||
|
approximation, and that is unchanged.)
|
||||||
|
* **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 pass costs the window's buckets times the pane's *measured channels* and its *distinct
|
||||||
|
levels*, and the window's own size multiplies all of it.** The cadence above bounds *how
|
||||||
|
often*; this is *how much*, and it is the term a big time scale buys. Measured on the mock at
|
||||||
|
the user's scale -- a **15 s** window, `--buffer-samples 100M` (so `time_stride` stays 1 and the
|
||||||
|
window is 11.8 M samples, 184 k level-0 buckets), four signals, one row -- with
|
||||||
|
`[levels-probe]` (see [verification.md](verification.md)), `measure cost:` reading
|
||||||
|
`recomputes`/`each`/`covered`/`buckets`/`raw`:
|
||||||
|
|
||||||
|
| pane | `each` | `raw` | tick mean |
|
||||||
|
|---|---|---|---|
|
||||||
|
| empty | -- | -- | 1.34 ms |
|
||||||
|
| 1 measurement, 1 level | 6.58 ms | 0.1 % | 1.99 ms |
|
||||||
|
| 4 measurements, 4 shares on **one** channel | 10.28 ms | 0.4 % | 2.38 ms |
|
||||||
|
| 16 measurements, 16 shares on one channel | 24.55 ms | 1.2 % | 3.86 ms |
|
||||||
|
| 4 measurements, 1 share each on **four** channels | 35.69 ms | 2.6 % | 4.58 ms |
|
||||||
|
|
||||||
|
So a pane is priced per measured **channel** (one `MeasureWindowReader`, one fetch, one scan)
|
||||||
|
and per **distinct share** (one accumulator), and the window's samples are the multiplier on
|
||||||
|
both: the same pane over a 2 M-sample window is the 1.2--1.6 ms a recompute this pane's
|
||||||
|
budget was fixed at, and a machine's ten noisy channels at a 15 s window is **tens of ms a
|
||||||
|
pass**, several times a second. That lands on the **tick** -- `update_measures()` runs inside
|
||||||
|
it -- so `frame cost:`'s tick mean is where a pane's cost shows (1.34 -> 4.58 ms above), and
|
||||||
|
it is *not* an ingest problem: `gaps=`/`packets=` stay exactly clean even though each
|
||||||
|
`MeasureWindowReader` holds `model->mutex()` for its whole life, because a socket buffer
|
||||||
|
absorbs a few tens of ms of producer. Read the tick mean and `each` before blaming the
|
||||||
|
producer.
|
||||||
|
Inside a pass the terms are, instrumented per window at that scale: the **fetch** 3.3 ms
|
||||||
|
(three out-of-line `MinMaxPyramid` accessors and a 48-byte `MeasureBucket` per bucket),
|
||||||
|
`measure_scan_buckets()` 0.8 ms, and **pass two** 7.5 ms -- dominated by the per-(bucket,
|
||||||
|
level) fold. That fold is why `measure_accumulate_bucket()` is `inline` in `measure.hpp`: it
|
||||||
|
is the innermost loop of a walk whose iteration count is the window's buckets *times* the
|
||||||
|
pane's levels, and as an out-of-line call in its own translation unit it cost **30 % of the
|
||||||
|
pass** (sixteen shares on one channel: `24.55 -> 17.25 ms` a recompute). A caller adding a
|
||||||
|
per-bucket type keeps it in the header, and a caller adding a bulk one may not let it grow a
|
||||||
|
call per bucket either.
|
||||||
|
The third term is `raw`, and it is the **signal's**: a level that falls inside a bucket's
|
||||||
|
`[lo, hi]` has to be walked sample by sample (the fold cannot split a bucket), so a signal
|
||||||
|
whose local range covers the level everywhere -- white noise, a noisy machine channel --
|
||||||
|
degrades the pass to O(window) *per level*: measured `raw=4.6 %` on the mock's `noise` channel
|
||||||
|
against `0.1 %` on its `sine` at the same window. It is the field to read before blaming the
|
||||||
|
pane for a slow run, and no cheaper walk fixes it.
|
||||||
|
- **`Top`/`Bottom`/`Amplitude` read a *flat* level, and the density that finds it is the buckets',
|
||||||
|
not the samples'.** A band-limited edge overshoots and rings -- the mock's `square` at 30
|
||||||
|
harmonics reaches ±1.179 where its plateau is ±1 -- so a measurement that reads the extremes, or
|
||||||
|
a mean over a side of a level, reports the ringing rather than the level the signal dwells at:
|
||||||
|
that is what made this change (`amplitude` read `1.179350` and `top` `0.986496` on the rippled
|
||||||
|
square, against `1.179350` of overshoot where the plateau is `1`). A flat level is a *density*
|
||||||
|
question: the plateau holds most of the samples and the ringing a few percent. So
|
||||||
|
`measure_scan_buckets()` folds a 64-bin histogram of the whole buckets' *midpoints* while it has
|
||||||
|
the list in hand -- one observation per bucket, each bin carrying its buckets' **exact** sum and
|
||||||
|
finite count -- and `measure_flat_levels()` takes, either side of the split, the densest bin that
|
||||||
|
holds at least half the densest one's buckets, **the outermost of those**, and the exact mean of
|
||||||
|
the samples of the buckets within **5 % of the window's peak-to-peak** of it. 5 % is *under* the
|
||||||
|
~9 %-of-swing Gibbs overshoot, so the ringing's excursion falls outside the band while a
|
||||||
|
plateau's own ripple stays inside it; the outermost-of-the-equally-dense rule is what makes a
|
||||||
|
waveform with *no* plateau -- a sine, a ramp -- read its peak instead of an arbitrary mid-range
|
||||||
|
bin that a shifted window would move. `Amplitude` is then `top - bottom`, a bench scope's
|
||||||
|
`Vamplitude`, and **not** the half-swing it used to be, nor the peak-to-peak swing.
|
||||||
|
The readings, fast mock, 2 s window, `[measure-trace]`, harmonics `0` → `30`: `top` `1.000000` →
|
||||||
|
`0.999983`, `bottom` `-1.000000` → `-0.999983`, `amplitude` `2.000000` → `1.999966`, against
|
||||||
|
`peak to peak` `2.000000` → `2.358701` -- the extremes keep the overshoot, and that separation
|
||||||
|
*is* the measurement. On the probe's 1 Hz ±1 sine: `top` `0.957988` (it was `0.636620`, the
|
||||||
|
half-wave mean), `amplitude` `1.915983`, and `peak to peak` still **exactly `2`**; `[stats-probe]`
|
||||||
|
reads `top` `mean=0.957905 sd=0.0014 lo=0.951967 hi=0.962348` over a scrolling window, which is
|
||||||
|
the stability the value band buys -- the answer does not follow the window's alignment.
|
||||||
|
Five things this rests on, each of which breaks something silently:
|
||||||
|
* **Nothing may be read raw for it.** The histogram is the *same* fetched bucket list, walked a
|
||||||
|
second time inside `measure_scan_buckets()`; a histogram of the window's *samples* would be a
|
||||||
|
second O(window) walk, which is exactly what the pyramid path exists to avoid. `measure cost:`
|
||||||
|
is the guard and its `raw` does not move: the probe's pane reads
|
||||||
|
`covered=59684972 buckets=932446 raw=47640 (0.1%)` against the baseline's
|
||||||
|
`covered=61183221 buckets=955856 raw=47402 (0.1%)` -- the same `raw`, 0.44 → 0.50 ms a pass
|
||||||
|
(and `--noise 0.1`, `2.1%` both sides, 0.52 → 0.65 ms).
|
||||||
|
* **It is gated, and the gate is what keeps an unrelated pane free.** `measure_scan_buckets()`'s
|
||||||
|
`flat_hist` comes from `App::MeasureWindow::flat_wanted`, set by the first loop over the pane
|
||||||
|
and only for one of the three types. A pane of means and extremes pays nothing.
|
||||||
|
* **The bin range is the *buckets'*, not the window's.** The window's partial edge buckets and the
|
||||||
|
samples produced since `acquire_all()` are read raw and are not in the list, so
|
||||||
|
`measure_scan_buckets()` tracks its own `blo`/`bhi` over the **non-empty** buckets only -- an
|
||||||
|
all-non-finite bucket is an empty aggregate whose `lo`/`hi` say nothing, and taking them would
|
||||||
|
put a bucket at some arbitrary value. A degenerate range (`bhi == blo`) leaves `folded` at 0,
|
||||||
|
which is the caller's "no histogram at all".
|
||||||
|
* **The *window's* peak-to-peak is what the band is a share of**, and the *split* is sized exactly
|
||||||
|
as `measure_set_level()` sizes a level, so "the top" and "the level a period is counted about"
|
||||||
|
are placed by one rule. The band is a *value* band, which is why the reading does not move when
|
||||||
|
the window does.
|
||||||
|
* **The fallback is the split, and it is what a filtered or sub-bucket window reads.** `hf_fc > 0`
|
||||||
|
disables the pyramid (above) and a window shorter than a bucket has no bucket to fold, so
|
||||||
|
`top`/`bottom` fall back to the mean of each side of the split -- and `amplitude` to the
|
||||||
|
window's own peak to peak, which is the same number on a waveform with nothing to exclude.
|
||||||
|
There is no flat level in that mode to subtract, so `amplitude == top - bottom` is *not* an
|
||||||
|
invariant to lean on there. Measured on the rippled `square` at `hf_fc=100`:
|
||||||
|
`top=0.973824 bottom=-0.991731 amplitude=2.354173 peak to peak=2.354173`, `buckets=0
|
||||||
|
raw=200.0%` -- two raw walks and no density estimate, which is the honest reading of a window
|
||||||
|
the pyramid cannot summarize.
|
||||||
|
- **`Above`/`Below`/`Between` are the *means over a side of a share*, and they are the readings
|
||||||
|
`Top`/`Bottom` gave before a flat level replaced them.** That change kept the split's two means as
|
||||||
|
its *internal fallback* for a window the pyramid cannot summarize (above); these three types are
|
||||||
|
that fallback, exposed and named, which is what lets the two different questions about one split be
|
||||||
|
asked side by side -- on a unit sine `above 50` reads `0.636620` (2/pi) where `top` reads
|
||||||
|
`0.957988`, and on the mock's band-limited square `above 50` reads `0.986496` where the plateau is
|
||||||
|
`1.000000` and `top` `0.999983`: the edge's ring is *inside* this mean and not inside that level.
|
||||||
|
Five rules, and the first two are what a reader is most likely to assume wrongly:
|
||||||
|
* **A band is not a wider split, and needs no walk of its own.** `Above`/`Below` are
|
||||||
|
`MeasureLevel::sum[1]`/`count[1]` and `sum[0]`/`count[0]` at the measurement's own share.
|
||||||
|
`Between` is the **difference of two levels' upper sides** -- `sum[1](lo) - sum[1](hi)` over
|
||||||
|
`count[1](lo) - count[1](hi)`, i.e. the samples above the lower edge and *not* above the upper
|
||||||
|
one -- and no single level's two sums can bound a band, because a band's two edges are two
|
||||||
|
thresholds. Both accumulators come out of one pass two over one window (the pane registers one
|
||||||
|
level per edge, `w.pct`, and both are deduplicated into it), so the difference is exact rather
|
||||||
|
than approximate. `v == level` is the below side, as everywhere, so the band is `(lo, hi]`.
|
||||||
|
* **It rides the bucket fast path unchanged.** A bucket wholly inside the band is strictly above
|
||||||
|
the lower level *and* strictly below the upper one, so both accumulators fold it whole; only the
|
||||||
|
buckets that straddle either edge are opened, exactly as for one level. `measure cost:` is the
|
||||||
|
guard: adding all three types to a pane of every type left `raw` at `0.1%` of `covered`
|
||||||
|
(`113966` of `240719031` with the ten types, `134978` of `255372033` with the thirteen), and a
|
||||||
|
band is *two entries of the level list*, not a second pass.
|
||||||
|
* **`measure_level_pcts()` is the only place the shares are read**, and it clamps both to `[0,100]`
|
||||||
|
and puts `Between`'s pair **in order**: "between 80 % and 20 %" is the band between 20 % and
|
||||||
|
80 %. Normalized rather than refused because the shares name two *edges*, and the band does not
|
||||||
|
depend on which the user typed first; the pane's dedup key and both accumulators are keyed on the
|
||||||
|
ordered pair, so one band is one walk however it was written.
|
||||||
|
* **Nothing to measure reads `--`**, exactly as for every other type: a side holding no sample (a
|
||||||
|
share at `100 %`, whose level is the window's own maximum) and an *interior* holding none are
|
||||||
|
both `invalid`/`--` and never `0`. The ideal `--square-harmonics 0` square is where the second
|
||||||
|
shows -- every sample is exactly +/-1, so the band `(0, 0.5]` holds nothing -- against the same
|
||||||
|
band on the band-limited square, which holds the edges' transitions (`between 50 75` =
|
||||||
|
`0.302744`, `between 25 75` = `0.000000`: the symmetric band's two halves cancel, the transition
|
||||||
|
being odd about its own midpoint).
|
||||||
|
* **The reading is a mean over the window's *samples*, so its sixth decimal follows the window's
|
||||||
|
boundary.** A live window is closed at both ends, so its oldest and newest sample share a phase
|
||||||
|
and the mean carries one sample that is not in an open interval -- about one unit in the sixth
|
||||||
|
decimal out of 2.6 M samples. Measured over one fast-mock run, `[measure-trace]`: `above 50` on
|
||||||
|
the sine printed `0.636620` on 48 lines and `0.636621` on 2, `below 50` `-0.636620` on 37 and
|
||||||
|
`-0.636619` on 18, `above 50` on the square `0.986496` on 32 and `0.986495` on 18, and
|
||||||
|
`between 50 75` on the ramp `0.250000`/`0.250001`/`0.249999`. The old `Top`/`Bottom` spread the
|
||||||
|
same way -- it is a property of a mean over a finite window, not of these types, and a reading
|
||||||
|
that has to be exact to six figures is read off `[bandavg-probe]`'s nine.
|
||||||
|
Measured on the fast mock, 2 s window, the mock's default 30-harmonic square, `[bandavg-probe]`
|
||||||
|
(nine significant digits, then what the pane prints):
|
||||||
|
`above 50` on the sine `0.636620262` -> `636.62 mV`; `below 50` on the sine `-0.636619283`;
|
||||||
|
`between 50 75` on the sine `0.255873597` -- the analytic `3(2-sqrt 3)/pi = 0.255873`, which is
|
||||||
|
neither the window's mean (`0`) nor the band's edge (`0.5`); `above 50` on the square
|
||||||
|
`0.986494746` / `0.986495505` (one or two samples at the window's end); `between 50 75` on the
|
||||||
|
`ramp` `0.250000385` and `above 50` `0.500000` -- a uniform density makes every band's mean that
|
||||||
|
band's own centre, so a quarter and a half of the range are the shape's prediction and not a
|
||||||
|
coincidence; `above 100` on the `ramp` `--`; and the ideal square's `between 50 75` `--`.
|
||||||
|
Editing the sine's band through the dialog to `50/90` takes it to `0.431361697`
|
||||||
|
(`0.8 / (2 asin 0.8)`), which is the check that the second share is the one the engine reads.
|
||||||
|
- **`MeasureOptions::noise_pct` is a percentage, clamped in `measure_set_level()` to half the room to the
|
||||||
|
nearer extreme**, so a band can never invert its own thresholds. It moves the **edge decision only**: the
|
||||||
|
sums still split at the bare level, which is the *fallback* `Top`/`Bottom` read on a window with no
|
||||||
|
bucket histogram -- a band that moved that split would change what a filtered or sub-bucket window
|
||||||
|
reports, for no gain to a crossing count.
|
||||||
|
- **`measure_accumulate` loops samples outer, levels inner, and that is not backwards.** The filter holds one
|
||||||
|
state for the whole walk, so levels-outer would step it once per level *per sample* and hand every level but
|
||||||
|
the last a different filter state.
|
||||||
|
- **A non-zero `hf_fc` disables *both* bucket fast paths**, with the single
|
||||||
|
`if (!filtered && reader.bucket_range(...))`. An empty `buckets` makes `bulk_from == bulk_to == n` *and*
|
||||||
|
leaves `lo_search` / `hi_search` at 0, so `pin()` becomes a no-op -- and that is the right answer, because a
|
||||||
|
bucket is an aggregate of unfiltered samples and cannot be mixed into a filtered walk in either direction.
|
||||||
|
- **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`.** Four things read it and none may be
|
||||||
|
moved by a band: a crossing's *side* is decided by the sample's own value against the level
|
||||||
|
(`v == level` is the below side, as it always was), `Top`/`Bottom` fall back to the mean of
|
||||||
|
each side on a window with no bucket histogram, and `Above`/`Below`/`Between` read it on
|
||||||
|
*every* window -- their whole definition is that split, so a band folded into it would move a
|
||||||
|
documented exact reading (2/pi on a sine) for no gain to any crossing count. The band is about
|
||||||
|
*deciding a crossing*, not about where a sample lives.
|
||||||
|
* **`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`.
|
||||||
|
- **A statistic's observation is one *recompute*, not one sample.** The pane already refreshes on
|
||||||
|
a cadence (at most one pass per 100 ms while the window scrolls, exactly once when it
|
||||||
|
settles), and the accumulator is fed from that pass's result — so `n` counts windows measured.
|
||||||
|
Feeding it per sample would make `n` a sample count and the mean a mean of *samples*, which is
|
||||||
|
not what any of these measurements measure: a frequency over a window is not a property of a
|
||||||
|
sample. The reading that names this defect is `[stats-probe]`'s `n` in the thousands rather
|
||||||
|
than the tens.
|
||||||
|
- **The accumulator is Welford's, and the reason is the same one the time ring is `f64` for.** A
|
||||||
|
measurement's values are routinely large with a small spread — an absolute stream time is the
|
||||||
|
obvious case — and `Σx² - (Σx)²/n` cancels to noise there. The standalone check is that the
|
||||||
|
spread of `{2,4,4,4,5,5,7,9}` survives a `+1e9` offset unchanged.
|
||||||
|
- **What resets the statistics is "what is being measured changed", not "what is in view
|
||||||
|
changed".** An explicit Reset, an add/edit/delete (all three funnel through
|
||||||
|
`rebuild_measures_pane()`), a CONFIG, and a change of display window each change the
|
||||||
|
*question*, so the history is cleared. A pause, a resume, a trigger latch, a pan and a zoom do
|
||||||
|
not: on a **live** view they change which samples answer it, which is precisely what averaging
|
||||||
|
over many windows is for -- and on a **pinned** one they change *nothing*, not even a value
|
||||||
|
(the window invariant above), so there is nothing for the history to see either way. The rule
|
||||||
|
is about the *accumulator* only; it is not a claim that a gesture leaves a value alone, which is
|
||||||
|
a property of the window a value is computed over and is the other half of this change. The
|
||||||
|
pane-wide toggle does not reset either — it only shows and hides labels that are always created
|
||||||
|
and always fed, so reading the plot for a moment does not cost the history.
|
||||||
@@ -0,0 +1,391 @@
|
|||||||
|
# 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 and the slide-over's overlay children, 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.
|
||||||
|
|
||||||
|
- **A sidebar's size must be its own, never a divider's position.** `GtkPaned` keeps a child's size in
|
||||||
|
`position`, which is a number of pixels **of the window**: the sidebar's width *was* `window - 940`, so on
|
||||||
|
any window narrower than 940 px — a scaled display, where the logical width is half what the screen looks
|
||||||
|
like, or a window a manager capped — the sidebar came up **0 px wide**, and the only way to see it was to
|
||||||
|
drag the divider back. Every patch that kept the paned failed for the same reason (the position derived
|
||||||
|
from the paned's own width, re-parenting the column, the property form of its children, size requests):
|
||||||
|
the *mechanism* was the fault. It is an **`AdwOverlaySplitView`** now — what a utility sidebar is in
|
||||||
|
GNOME, as against `AdwNavigationSplitView`, which is for navigation lists — with `min-sidebar-width` =
|
||||||
|
`max-sidebar-width` = 280 in **pixels**, so the width is its own at any window size, and `collapsed` (set
|
||||||
|
from the window's width in `tick()`) to *overlay* the plot when there is no room beside it rather than
|
||||||
|
being squeezed. `[sidebar-probe]` reads both states: `window=756 sidebar=280` under a 2x scale is exactly
|
||||||
|
the case that used to be zero.
|
||||||
|
- **The sidebar's button is the only input; `AdwOverlaySplitView:show-sidebar` is an output.** The split
|
||||||
|
view writes that property *itself* — at startup, and when it collapses — with no user involved, so reading
|
||||||
|
it back as the user's wish is wrong: doing that made connecting a source leave the sidebar shut, because
|
||||||
|
the widget's own `false` was recorded as the user asking for it. A `GBinding` from the property to the
|
||||||
|
toggle is the same mistake in one line, and it cannot be told from a real change by a "is this our own
|
||||||
|
write" flag either, since the toggle's signal may fire after the write returns. Instead `App::sidebar_wanted`
|
||||||
|
holds the wish (written only by `on_sidebar_toggled`, guarded by `App::sidebar_syncing` around our own
|
||||||
|
`set_active`), `update_side_visibility()` is the single place that turns that wish into the panel's visibility
|
||||||
|
and "are there signals" into the dock's *sensitivity* (the button stays sensitive), and `tick()` re-asserts both when the widget disagrees —
|
||||||
|
which is also why a hide *gesture* does not stick: the panel stays where the button says it is. The
|
||||||
|
no-signals case must leave the wish alone, or a source connecting later finds a sidebar the user never closed.
|
||||||
|
The wish is the session's `dock` key: a change of it (not a program's own write, and not a restore) schedules a save.
|
||||||
|
- **`AdwOverlaySplitView:sidebar-width-fraction` is a 0..1 fraction of the window, whatever
|
||||||
|
`sidebar-width-unit` says.** Setting a pixel count there (280) is a `GObject` critical, and it silently
|
||||||
|
falls back to 0.25 — which reads as "the sidebar is mysteriously 560 px wide" on a wide window with no
|
||||||
|
error anywhere the build would catch. Pin the width with `min-sidebar-width`/`max-sidebar-width`.
|
||||||
|
- **A `GtkPaned`'s position is stated after both its sides are filled**, and that is a fact about the
|
||||||
|
*function's order* rather than about the window's shape, which is why it is the one part of the
|
||||||
|
window skeleton that cannot live in `main-window.ui`. `gtk_paned_set_position()` clamps against what
|
||||||
|
its children ask for *now*: called before the sidebar's panels have been appended it is clamped
|
||||||
|
against an empty pane, and the divider lands wherever that left it. Moving the call into the `.ui`
|
||||||
|
moved the plot column by about 180 px, which the layout probe read as a different `done_box` and a
|
||||||
|
taller figure.
|
||||||
|
|
||||||
|
- **Events never take the plot's width.** The events list is a slide-over page (an overlay child over the plot) and
|
||||||
|
the dock's Events block lives in the fixed 280 px column, so there is no events divider, revealer or pane beside the
|
||||||
|
plot to squeeze it; the first latch must not move the plot area (`[events] ... plot_w_stable=1`, which compares the
|
||||||
|
allocated width before and after). A dock row is a fixed-width ellipsized label (title at 18 characters, `#seq`), the
|
||||||
|
list is a fixed 112 px scroller capped at `kDockEventsMax` rows plus one "N more" row, and it has no thumbnails: the
|
||||||
|
dock's width and height do not depend on how many events there are or what they say ("layout must not depend on
|
||||||
|
content"). The thumbnails are the *page's* (138 px wide).
|
||||||
|
|
||||||
|
- **`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 shape (its cells per row) 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.
|
||||||
|
- **A figure row can be split into cells, and every plot index in the host is the flattened one.**
|
||||||
|
`RowSet::row_count()` is the number of **plots** and `row(i)`, `Row::plot`, the cursors, the
|
||||||
|
measurements, `bind()`, the readouts and the persistence layers all index plots row-major (row 0's cells
|
||||||
|
left to right, then row 1's); `figure_row_count()` is the number of figure rows, and
|
||||||
|
`cells_in_row()`/`first_plot_of_row()`/`figure_row_of()`/`cell_of()` translate. **Code that means "the
|
||||||
|
rows of the figure" -- the editor's row `+`, the row drag, the row weights, `fl.rows` -- must say
|
||||||
|
`figure_row_count()`**: with no split the two are equal and a slip passes every unsplit probe, which
|
||||||
|
is how a row weight once drifted off its row (see the `row-weights` reading). The caps are
|
||||||
|
`kMaxCellsPerRow` = 4 per row and `kMaxRows` = 8 **plots** in all (the constant bounds plots now, not
|
||||||
|
figure rows), so every per-plot structure sized for 8 is unchanged. `plot_label()` is what the UI
|
||||||
|
prints for a plot: the row's number (`2`) for a one-cell row, the number and a letter (`2a`..`2d`) for
|
||||||
|
a split one -- never a flat index, which is 0-based and says nothing about where the plot is.
|
||||||
|
What the cells share and what each keeps:
|
||||||
|
- **One time window.** Every cell of every row is a member of the one x link group, so a pan or zoom
|
||||||
|
in any cell moves them all, the crosshair is one x drawn in every cell, and the **vertical cursor
|
||||||
|
pair is one shared time drawn in every cell** (`cursors_aligned`). The horizontal crosshair line is
|
||||||
|
the hovered cell's y **drawn in every cell of its figure row** -- the cells share that y, so the
|
||||||
|
line is one value spanning the row rather than stopping at the divider; no other row gets one
|
||||||
|
(`Figure::set_crosshair` spreads it, `[split-probe] cell-xhair` reads it).
|
||||||
|
A split is an arrangement of more plots, not a second time scale: nothing about the time axis changes
|
||||||
|
(see [invariants-time-axis-and-window.md](invariants-time-axis-and-window.md)).
|
||||||
|
- **One y per figure row.** The cells of a figure row share one y range (the plotter's
|
||||||
|
`Figure::set_share_row_y(true)`, which `rebuild_all()` sets before the cells are populated): one y
|
||||||
|
axis (auto or fixed), one pair of horizontal rulers, one fit. A wheel or axis drag in any cell moves
|
||||||
|
the row, an x gesture on any cell that carries an x strip moves the shared window. The host therefore acts on the
|
||||||
|
**figure row** of a plot it is given (`set_y_auto`, `set_y_range`, `fit_all`, the Auto/Fixed dialog,
|
||||||
|
`row_unit`, which joins the units of every cell and reads "" if they differ). The rulers are stored on
|
||||||
|
the row's first plot index, copied to the other cells by `sync_cursors()` / `insert_cell()` /
|
||||||
|
`rebuild_all()` and placed in every cell by `place_cursors()`. The cursors pane lists **one ruler line
|
||||||
|
per figure row**: `row N` with N the 1-based figure row for a split row, and the 0-based plot index
|
||||||
|
for an unsplit row (the pane read that before splitting existed). Readouts and measurements stay per
|
||||||
|
trace and per plot index; only the axis is shared.
|
||||||
|
- **The x axis is the bottom row's for every run of rows with the same columns.** A figure row gets
|
||||||
|
`XStrip::None` only if the row directly below has the same columns (same cell count and same
|
||||||
|
normalised cell weights); a row whose columns differ keeps its own strip under each of its cells
|
||||||
|
(`[1][2]/[3]` has axes on 1, 2 and 3), because the axis below is at another width and scale. The
|
||||||
|
rule is structural and transitive, never a function of content. Every cell that carries a strip is
|
||||||
|
a place a middle-drag on the axis works. The `t0` caption belongs on every cell that carries a strip,
|
||||||
|
not only the last figure row's.
|
||||||
|
- **The right gutter is the last cell's.** In a split row with a shared y the plotter gives
|
||||||
|
`right_gutter` to the last cell alone and takes it off the row like the y gutter, so the visible gap
|
||||||
|
between two plots is `col_gap` and equal weights give equal axes widths.
|
||||||
|
- **One left gutter, on the first cell only.** The y gutter is one maximum for the whole figure with the
|
||||||
|
same reserved floor as before (`Figure::set_min_y_gutter()`). It is carried by the first cell of each
|
||||||
|
row alone: `Figure::layout` gives every other cell `YStrip::None` from its column index (structurally,
|
||||||
|
never from what it holds), which forces its y label width, title width and reserved floor to 0. A
|
||||||
|
`None` cell contributes 0 to the common maximum, so a split row's non-first cells cannot widen the
|
||||||
|
gutter and a row's gutter does not depend on how many cells it has. A per-column gutter sized by the
|
||||||
|
labels would make a cell's width depend on its content, which "layout must not depend on content"
|
||||||
|
forbids. A row whose labels are narrower than the gutter simply has a wider margin. The weights apply
|
||||||
|
to **plot widths**, not cell rects (below).
|
||||||
|
- **Widths are weights with a floor.** The plotter keeps 64 px logical per plot and a column gap
|
||||||
|
(`kColGap`, 6 px); the editor's drag is stricter (below). The floors and the weights act on **plot widths**: the row's y gutter (carried by the first
|
||||||
|
cell) is taken off the row width first, then the cells share the rest, so equal weights give equal plot
|
||||||
|
areas and the first cell's rect is its gutter plus its plot width. A weight is a share of what is
|
||||||
|
left *after* the gutter and the floors, so cells of `{3, 1}` read 524:229 (rects) in a 756 px
|
||||||
|
figure, not 3:1, and the editor's 96 px drag floor is a floor on the plot *width*, not on the share. Because a cell's width is
|
||||||
|
`64 + extra * weight / total` and not proportional to its weight, `layout_col_apply()` does not scale
|
||||||
|
the pair's weights by the width ratio: it keeps the pair's weight sum `S` from the drag's *start*
|
||||||
|
(`layout_col_drag_start`) and gives the left cell `S * (want_left - 64) / (pair - 128)`, clamped to
|
||||||
|
`[0.001, 0.999]` (the plotter refuses non-positive weights; `0.5` if the pair is not wider than 128).
|
||||||
|
`want_left` and the pair are plot widths (`layout_col_drag_begin` reads each plot's `layout().axes.w`, after a `layout()` on the current figure, so the gutter and the inter-axes gap are never counted as weight); `want_left` is the pointer's width clamped to `[96, pair - 96]` logical px. The divider therefore
|
||||||
|
follows the pointer, the shrinking cell reads 96 px at the extreme and the pair keeps its total width
|
||||||
|
(the split probe's `col-drag` line asserts all three). `kPlotterMinCellPx` (64) mirrors the plotter's
|
||||||
|
`kMinCellWidthLogical`; both are logical px (the plotter scales it by `ui_scale`, which is
|
||||||
|
`PlotArea::device_scale()`, the divisor used to turn layout rects into logical px), so the two
|
||||||
|
must change together.
|
||||||
|
- **A cell is removed with its traces, and a new cell is empty.** `insert_cell(fr, at)` pushes the
|
||||||
|
cells at and after `at` right *with their contents* (the same rule as `insert_row()`), and
|
||||||
|
`remove_cell(plot)` on a one-cell row removes the row. Both return false at a cap or when they would
|
||||||
|
leave no plot, and never grow past 4 / 8.
|
||||||
|
**The editor's controls** (`rebuild_layout_editor()`): a cell `+` (`kLayoutCellKey`, tooltip "split
|
||||||
|
here") at a row's left edge, at its right edge and at every internal boundary -- `n + 1` of them for a
|
||||||
|
row of `n` cells, so a one-cell row can be split from either side -- inserting at that position; a drag
|
||||||
|
double-headed arrow (`kLayoutColKey`, `udpscope-resize-cols-symbolic`) at each internal boundary that moves the boundary between
|
||||||
|
its two neighbours, **each floored at `kMinCellPx` = 96 px logical** (or half the pair when the pair is
|
||||||
|
narrower) and measured from the two widths at drag start, so the drag does not compound. **The handle follows the divider it
|
||||||
|
drags, so the gesture's offset shrinks by the handle's own displacement**: `on_layout_col_update` adds it back
|
||||||
|
(`layout_col_total_offset(offset, handle_x_now, handle_x_at_begin)`, the handle's x in overlay coordinates, stored by
|
||||||
|
`on_layout_col_begin` in `layout_col_drag_handle_x0`) before multiplying by the scale; without it the divider settles at half the pointer's travel. The per-plot
|
||||||
|
`×`, which removes the cell in a split row ("remove this plot") and the row in a one-cell row ("remove
|
||||||
|
this row"), insensitive when it would leave no plot. Cell `+` is insensitive at 4 cells in its row or 8
|
||||||
|
plots in all; the row `+` is insensitive at 8 plots. **The cell `+` is keyed by `kLayoutCellKey` (figure row * 8 + position + 1) and
|
||||||
|
never by `set_row_key()`**: `row_key()` is a plot or boundary index, `layout_button()` finds a button by it,
|
||||||
|
and a cell `+` that also carried one would be found as a row button; `sync_layout_editor()` tells the kinds
|
||||||
|
apart by which key it finds. Like every editor control the
|
||||||
|
cell buttons are placed from `FigureLayout::cells`, divided by `device_scale()`, and the editor does
|
||||||
|
not draw while the figure's rects are not yet one per plot.
|
||||||
|
- **The slide-over is the second thing placed over the plot, and it is one opaque widget, not a
|
||||||
|
transparent area.** The same pick rule as the editor's buttons applies, harder: the panel is a
|
||||||
|
`GtkRevealer` aligned to the end edge (`halign=END`, `valign=FILL`) at `kSlideOverWidth` = 360 px, so
|
||||||
|
its allocation *is* the part of the plot it covers and everything to its left is still the plot's --
|
||||||
|
pan, zoom, drop and hover are untouched. A full-area child with a panel drawn in it would swallow all
|
||||||
|
of them. Closed, the revealer is `can_target(FALSE)` (and `gtk_overlay_set_clip_overlay()` keeps its
|
||||||
|
slide-out inside the plot), so the frames of a slide-out do not eat input either. `[slideover]` reads
|
||||||
|
it back: `outside_hits_plot=1 inside_hits_panel=1`. Three consequences:
|
||||||
|
the panel **never resizes the plot** (it is an overlay child; `plot_unchanged=1`), its height is the
|
||||||
|
plot's and never the page's (each page has a `GtkScrolledWindow` with a `NEVER` horizontal policy that
|
||||||
|
does not propagate its child's height), and content that is taller than the plot -- the trigger form
|
||||||
|
on a 470 px window -- scrolls inside the panel instead of growing it. The widgets that go in a page
|
||||||
|
are reparented into it; their widths must fit 360 minus the border (the trigger form measures 267
|
||||||
|
minimum, the E-1608's panels 197). **One page wider than that widens the panel
|
||||||
|
for all of them** (the stack measures every page): the signals page's hint line once did, unwrapped, and
|
||||||
|
`[slideover] panel=` read 376 instead of 360; the label now wraps.
|
||||||
|
- **The badge bar has a fixed height and sits outside the overlay.** `badge_bar` is 36 px
|
||||||
|
(`height-request`) between `plot_overlay` and the status line, so the plot above it never moves when a
|
||||||
|
badge's text changes (`bar_h` = `bar_h_after` in `[badges]`). This is "layout must not depend on
|
||||||
|
content" for the new row: the Trigger badge's state is a word from a short fixed set and the Source badge
|
||||||
|
(shown only when the kind has source pages; hidden otherwise) is a kind's display name only (no connection state: that is the status bar's), so what changes with the stream is the row's *width* (the bar has room to
|
||||||
|
spare on the right) and never its height. The badge labels are not ellipsized: a name long enough to
|
||||||
|
need it would be a defect of the kind's `display_name()`. Rows that appear by trigger condition appear
|
||||||
|
inside the slide-over's scroller, never in the bar.
|
||||||
|
- **The signal badges live in a scroller, so the bar's minimum width never depends on the signal count.**
|
||||||
|
The bar's signals slot holds a `GtkScrolledWindow` (horizontal `EXTERNAL`, vertical `NEVER`) around one
|
||||||
|
row of badges and the `+`, because a button row's minimum width is the sum of its children: nine badges
|
||||||
|
(the E-1608: eight inputs and the counter) would push the window's minimum width past a laptop screen.
|
||||||
|
Past `kMaxSignalBadges` (6) the row is replaced by one "N signals" badge, which also stands for the `+`.
|
||||||
|
The bar's height stays 36 px in every one of these states (`bar_h_fixed` in `[sigbadges]`), and
|
||||||
|
`bar_min_w` reads the same with one signal, nine signals and the overflow (244 on the E-1608, 124 on UDPS:
|
||||||
|
the Source badge is on screen for the E-1608 and hidden for UDPS, and its text differs by kind). A change that makes it grow with the signal count has
|
||||||
|
taken the scroller out of the measurement.
|
||||||
|
- **The dock's width is fixed too, and it is the readouts' width that holds it there** (`side_column`
|
||||||
|
`width-request` 260, the split view's 280 px floor and cap). The dock holds only the compact readouts and the Events block (a fixed-height list, below), so a
|
||||||
|
section's visibility is never what makes it taller; the scroller absorbs the rest. **The readouts' minimum
|
||||||
|
width is the sum of their cells' character widths, and it must stay under 280** (the Readouts box is 268:
|
||||||
|
280 less its 6 px margins): a cursor line is the `who` cell (5 characters) plus three value cells (9 each),
|
||||||
|
every one ellipsized at that width (`width-chars` = `max-width-chars`), so a long value is cut and its
|
||||||
|
tooltip carries the text and the layout never depends on what a value reads. The `who` cell was 8
|
||||||
|
characters, which made the readouts' minimum width 304 px; that pushed the sidebar to 304 px
|
||||||
|
(`dock_w=304`) and defeated the 280 pin. Widening any cell is the same
|
||||||
|
trap -- read `[readouts] ... dock_w=` (must be 280) and the `dock cursors:` / `dock measures:` `fits=`, and for the Events block `dock events: ... dock_w=` (268).
|
||||||
|
- **The readouts strip is a fixed 26 px, scrolls sideways, and is shown only when the dock is not beside the plot and there is something to read.**
|
||||||
|
`readouts_strip` (a `GtkScrolledWindow`, `height-request` 26, horizontal `external`, vertical `never`) sits
|
||||||
|
in `left_column` between `plot_overlay` and `badge_bar`. Its height is a request and not a measurement of
|
||||||
|
its chips (the buttons are trimmed by `button.udpscope-strip-button` in `ui_widgets.cpp`, or Adwaita's
|
||||||
|
34 px flat button would push it past 26), the chips' cells are fixed-width ellipsized labels like the
|
||||||
|
dock's, and an overflow scrolls with no bar taking height: "layout must not depend on content" for the
|
||||||
|
strip, the same as for the badge bar. It is *visible* exactly when (`show-sidebar` is false **or** the split view is `collapsed`)
|
||||||
|
**and** (the cursors are on **or** there is a measurement)
|
||||||
|
(`sync_readouts_strip()`, from the tick after the wish is re-asserted and before `set_collapsed()` -- so
|
||||||
|
`show-sidebar` is the wish and not the split view's collapse-time write, and `collapsed` is the previous
|
||||||
|
frame's value). Hidden with a wide window and the dock beside the plot, so the plot's 26 px go to one
|
||||||
|
pane of readouts and never both, and hidden with nothing to read (cursors off, no measurement). That last
|
||||||
|
is a deliberate, user-requested exception to "disable, do not hide": an empty strip is 26 px of nothing, and
|
||||||
|
the dock keeps both blocks on screen so the empty state is never invisible; the strip's two groups and the
|
||||||
|
separator follow the same rule (`strip_cursor_group` with the cursors, `strip_measure_group` with
|
||||||
|
measurements, `strip_sep` with both). In a collapsed window the dock is an overlay on the plot and the strip
|
||||||
|
stays, overlay open or not. The plot is 26 px shorter while it shows; that is the dock's and the window's
|
||||||
|
state moving the layout, not content. Reading `h=26` with `visible=1` (and `h=0` with `visible=0`) in
|
||||||
|
`strip:` is the assertion, `visible_ok=1` that `visible` equals `expected` and `groups_ok=1` that the groups agree.
|
||||||
|
- **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.
|
||||||
|
A split row keeps this rule: cell widths come from the weights and the 64 px floor and never from what
|
||||||
|
a cell's labels need, and the gutter stays one figure-wide maximum.
|
||||||
|
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.
|
||||||
|
- **The invalid-span band is data-anchored and reserves no layout space.** `Plot::set_invalid_spans()`
|
||||||
|
draws a tinted quad over the span it names; unlike `Plot::set_x_note()` (the `t0` caption), which
|
||||||
|
is chrome drawn at a fixed corner of the axes regardless of the data, a band pans and zooms with
|
||||||
|
the samples it accuses. The two are the complementary cases: the caption is chrome that data does
|
||||||
|
not move, the band is annotation that data does. Both are alike in the second count: neither
|
||||||
|
reserves layout space and neither can be picked, so a hole cannot move the layout the labels were
|
||||||
|
measured into.
|
||||||
|
|
||||||
|
The band has a **one-device-pixel floor on its width**. A hole of one sample in a window of hundreds
|
||||||
|
of thousands is a hundredth of a column, and a fill rounded to zero would drop exactly the case
|
||||||
|
the band is most needed for — a sub-pixel hole is invisible as a gap in the polyline and would be
|
||||||
|
invisible as a band too without the floor.
|
||||||
|
|
||||||
|
The band carries **three states** and they are a property of the hole, not of the plot: solid
|
||||||
|
(`Style::invalid_fill`) while the hole still lacks packets, faint (`Style::invalid_repaired_fill`)
|
||||||
|
once `Hole::repaired()` is true, absent once the hole's slots age out of the ring and
|
||||||
|
`forget_holes_before()` drops it from the table. A repaired hole is **not** retired early: the band
|
||||||
|
is a record of what the window was missing, and retiring it on repair would erase the report the
|
||||||
|
moment it became explicable — the faint tier is how the display says "this was missing but arrived
|
||||||
|
late". `RowSet` sends one `plot::InvalidSpan` per hole to every row — a gap is in the stream, not
|
||||||
|
in one signal — and only in `Live` and `Capturing`, because a `Held` or `Paused` snapshot carries
|
||||||
|
its own stamps that the live hole table does not address.
|
||||||
|
|
||||||
|
- **`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.
|
||||||
|
|
||||||
|
- **Switching a signal off takes its traces off every plot.** The switch means "do not acquire this", and a
|
||||||
|
trace left behind draws nothing: it holds one of the four slots a plot has, reads `--`, and blocks the drop
|
||||||
|
that would fill it with something live. `on_signal_switch()` clears every slot by the **signal name** the
|
||||||
|
slot remembers -- never the channel index, which is exactly what the enable change renumbers -- and says what
|
||||||
|
it did in a notice, because a trace that vanishes silently is its own kind of confusing. The follow-ups (a
|
||||||
|
relayout, the panels' chips, the layout save) are done once by the caller rather than per slot. Re-enabling
|
||||||
|
does **not** put the traces back: the arrangement is the user's, and guessing where they were is worse than
|
||||||
|
leaving them off.
|
||||||
|
|
||||||
|
- **The full-screen toggle and a scope window are *views*: neither edits the arrangement nor takes a second
|
||||||
|
acquisition.** The per-row toggle (`RowSet::toggle_maximize_figure_row()`) saves every row's persistent
|
||||||
|
state (`RowState` -- the fields `carry_row()` copies, because a `Row` is not copy-assignable: its
|
||||||
|
`SlotSeries` owns a `unique_ptr`) plus the cells, the weights and the plot count, rebuilds the figure with
|
||||||
|
the asked-for row's plots moved to the front, and puts every field back on the second click; it saves
|
||||||
|
nothing, so a reopened session has the arrangement, not the view. Its overlay button is on screen
|
||||||
|
*always* (`App::maximize_buttons`, a set of its own -- `layout_buttons` is the editor's and is cleared with
|
||||||
|
it), placed from the same `FigureLayout` rects as the editor's buttons. A scope window
|
||||||
|
(`App::ScopeWindow`, `open_scope_window()`) owns a `RowSet`, a `PlotArea` and the area's placeholder
|
||||||
|
`Plot`, and is driven by the **main window's tick**: the same model mutex binds and acquires its rows
|
||||||
|
(same display mode, same epoch), and its span follows the main window's window or pin every frame. It is
|
||||||
|
therefore a second *view* -- the settle rule is that a scope must never acquire, plan or epoch anything of
|
||||||
|
its own. Closing the main window destroys its scopes first: a leftover toplevel keeps the GApplication's
|
||||||
|
loop alive around a window that is gone (the exit run's hang). What a user *arranges* in a scope is the
|
||||||
|
scope's own arrangement, and it takes the same two doors the main window's rows take: a badge **dropped on
|
||||||
|
a scope window's plot** lands in that window's row set on the cell under the pointer (its own drop target,
|
||||||
|
its own `assign_signal_to_scope()`), and the signal page's *Plot on* section carries **one named group per
|
||||||
|
window** -- rebuilt out of `App::scopes` on every fill, and again when a window closes (the destroy handler
|
||||||
|
refreshes the pages before the deferred delete can dangle a button's scope pointer). The change itself is
|
||||||
|
never applied synchronously: a drop arrives from inside the very widget it rearranges, so the rebuild and
|
||||||
|
the area's re-point are the window's next frame in the tick.
|
||||||
|
|
||||||
|
- **A figure row's full-screen is one click from two places, and never from one figure.** The
|
||||||
|
toggle sits on the row's top-right corner, **out of hiding only while the pointer is near that
|
||||||
|
corner**: its own placed rect inflated by `kMaximizeRevealPx`, per row, so approaching one
|
||||||
|
row's corner reveals that row's toggle and no other. The pointer is read from a
|
||||||
|
`GtkEventControllerMotion` on the **overlay**, not on the GL area -- a revealed toggle sits
|
||||||
|
under the pointer, and a leave on the GL widget would fire the moment the button appeared and
|
||||||
|
hide it again (the flicker the ancestor avoids); a leave on the overlay means the pointer left
|
||||||
|
the plot, which is when the toggles go away. The plot menu offers the same action as *Full
|
||||||
|
screen this figure* / *Restore the layout*;
|
||||||
|
both end in the same `toggle_fullscreen()` -- the `RowState` save/restore, the area's figure,
|
||||||
|
the toggles and the signal panels -- so the two cannot drift apart. A layout of a single
|
||||||
|
figure row carries **no** toggle (and no menu entry): there is nothing to enlarge *to*, and a
|
||||||
|
visible no-op is worse than none. The button reappears while a row *is* maximized, because
|
||||||
|
there it is the way back.
|
||||||
|
|
||||||
|
- **A view's arrangement is the view's, editor included.** A scope window holds its own figure
|
||||||
|
rows and edits them with its own `+`/`×`/Done set (`set_scope_layout_editing()`,
|
||||||
|
`rebuild_scope_layout_editor()`, `sync_scope_layout_editor()`): the buttons are tagged with the
|
||||||
|
window and the handlers act on *that* window's row set, so the two editors cannot move each
|
||||||
|
other's figures. `L` acts on the window the key arrived in (a `GtkEventControllerKey` per
|
||||||
|
window; every other accelerator reaches the application through `run_app_action()`), and a
|
||||||
|
pinned layout change is **not** written to the main window's session — the windows themselves
|
||||||
|
are not persisted.
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# 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.
|
||||||
|
- **A measurement with a non-zero `hf_fc` is the one exception**, and it is inherent rather than a rounding
|
||||||
|
error. Its `min`, `max`, `peak to peak`, `average`, `top` and `bottom` are computed from a one-pole-filtered
|
||||||
|
copy of the window, so the quoted value need not equal any stored sample -- and with `show_on_plot` its
|
||||||
|
ruler sits at a level the drawn trace, which is raw, never reaches. This is what filtering *is*; it must
|
||||||
|
not be "fixed" by snapping the readout to the nearest sample. Every measurement with `hf_fc == 0`, which is
|
||||||
|
the default, still quotes a stored sample exactly as the rule above says. The filter is seeded with the
|
||||||
|
window's **first sample** and never from history before the window, so a measurement never depends on data
|
||||||
|
outside the window it reports on: the left-edge step transient is removed by the seeding, not by priming
|
||||||
|
from earlier samples.
|
||||||
|
- **The dock's readouts and the pages' panels are one string, never two.** The dock (`src/ui_dock.cpp`)
|
||||||
|
shows a read-only copy of the cursors table and of the measurement values, and it must not format
|
||||||
|
anything: `update_cursors_pane()` and `update_measures()` format each value **once**, into a local, and
|
||||||
|
hand that same variable to the page's label **and** to `dock_cursor_cell()` / `dock_measure_value()` --
|
||||||
|
on every branch, including the `"--"` blanking one (cursors off, no sample). A second formatter in the
|
||||||
|
dock would be two spellings of one reading that drift the first time `format_measure()` changes, and
|
||||||
|
the dock exists so that the two can be read side by side. The `who` cell is copied from the page's label
|
||||||
|
verbatim (markup chip and name), and the dock's lines are rebuilt from the page's whenever the page
|
||||||
|
rebuilds (`dock_rebuild_cursors()`, `dock_rebuild_measures()`), so the line counts agree by
|
||||||
|
construction. A cell is **ellipsized at a fixed character count** (who 5, values 9, measurement type 12) with
|
||||||
|
the full text in its tooltip: the dock is 280 px wide and its layout must not depend on what a value
|
||||||
|
reads, so a long value is cut and quoted whole in the tooltip rather than widening the column (the
|
||||||
|
`who` cell was 8 characters, which made the readouts 304 px wide and pushed the sidebar past its
|
||||||
|
pin). `[cursors-probe] ... dock cursors:` and `[measures-probe] ... dock measures:` assert it: `same_text=1`
|
||||||
|
compares `gtk_label_get_text()` (the untruncated text) of every dock label with the page's, and
|
||||||
|
`fits=1` that the Readouts box is no wider than the dock.
|
||||||
|
- **The readouts strip is a third reader of the same string, never a second formatter.** When the dock is
|
||||||
|
not beside the plot (hidden, or the window collapsed so the dock is an overlay) the readouts are shown in the strip under the plot (`readouts_strip`, `src/ui_dock.cpp`): the
|
||||||
|
cursor chips' `who` and *delta* labels and the measurement chips' value labels. They are written **in the
|
||||||
|
same call** as the dock's -- `dock_cursor_cell()` and `dock_measure_value()` set the strip's label from the
|
||||||
|
argument they were handed, and `dock_rebuild_cursors()` / `dock_rebuild_measures()` rebuild its chips
|
||||||
|
beside the dock's lines -- so a value is still formatted once, into one local, and a feature that writes a
|
||||||
|
dock cell without going through those two setters leaves the strip stale. The strip keeps the dock's
|
||||||
|
rules: the same fixed-width ellipsized cells (who 6, type 10, value 9 characters, full text in the
|
||||||
|
tooltip), the delta cell reads `--` on the same branches, and the cursor chips are *insensitive* with the
|
||||||
|
cursors off -- through `dock_set_cursors_sensitive()`, which writes the dock's grid and the
|
||||||
|
strip's chip box together; a second `gtk_widget_set_sensitive()` on either is two spellings of one state.
|
||||||
|
The one place the strip hides what the dock only disables: with the cursors off the strip's Cursors group is
|
||||||
|
not shown, with no measurement its Measures group is not, and with neither the strip is not (the dock keeps
|
||||||
|
both blocks, `no measurements` included; the strip has no placeholder). That is
|
||||||
|
`sync_readouts_strip()`, not a sensitivity.
|
||||||
|
`[cursors-probe]` / `[measures-probe]` `strip: ... same_text=1` asserts it.
|
||||||
|
- **`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`.
|
||||||
|
- **With the instruments off the readout blanks, and the panel stays on screen.** Cursors-off makes the
|
||||||
|
pane *insensitive* (the page's body, and the dock's cursor grid beside it) rather than hiding it -- a panel you can still see is a panel you can read, and the
|
||||||
|
settings you are about to switch back on must not vanish while you look for them -- and every cell in
|
||||||
|
it is written `"--"`, the spelling an unavailable cell already uses. A readout quotes a stored sample;
|
||||||
|
with nothing bracketing one there is no sample to quote, and keeping the last values would be quoting
|
||||||
|
a sample the cursors no longer name. Two spellings of one state in one grid would be the defect.
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
- **The crosshair's axis badges are painted *over* the tick labels, and that costs a flush.**
|
||||||
|
A badge is a fill in the crosshair's colour framing the value where the line meets the axis
|
||||||
|
it names — and it sits *in the label strip*, on top of the tick label at that row. The tick
|
||||||
|
labels are staged into `glyphs_over_`, the over-text batch the frame flushes **after every
|
||||||
|
line batch**, so a panel fill drawn at the ordinary moment lands *under* them: the label
|
||||||
|
prints through the badge and the backplate reads as transparent (the defect this rule was
|
||||||
|
learned from). `draw_crosshair_axis_badges()` therefore flushes the accumulated over-text
|
||||||
|
*first* — those labels are already placed, so it is one batch — then draws both fills, then
|
||||||
|
stages the badge texts, which leave with the rest of the over batch over everything. An
|
||||||
|
assertion on badge pixels inside a label's rows is the regression test: with the fill under
|
||||||
|
the text, the label's glyph pixels are still lit inside the badge's rect.
|
||||||
|
|
||||||
|
- **The instruments have one owner: the main window's row set.** The two cursors, the gate band,
|
||||||
|
the trigger marker and the measurement marks are *shared*, not copied per window. The main tick's
|
||||||
|
`sync_scope_instruments()` writes the owner's state into every scope window each frame (the
|
||||||
|
setters early-out, so a steady frame is a handful of comparisons), and a gesture inside a scope
|
||||||
|
window is read back from **that** window's controllers first -- `sync_cursors()`/`sync_gate()`
|
||||||
|
only adopt while their own plot holds the grab, which is exactly "the drag happened here" -- and
|
||||||
|
adopted by the owner before anything is written out. The measurement marks travel by **signal
|
||||||
|
name** (`RowSet::MeasureMark::signal`), so a window finds the row that plots it; a window that
|
||||||
|
does not plot the signal draws none. The search marks are times on the window and are placed on
|
||||||
|
every window by the same per-frame path.
|
||||||
@@ -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,217 @@
|
|||||||
|
# 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 trigger is persisted, settings and state both.** Its condition, its channel (by *name*), its
|
||||||
|
pre-trigger share, hold-off and mode, and whether trigger mode was on — written even while it is off,
|
||||||
|
because a trigger configured once and switched off is still the trigger you come back to. The two
|
||||||
|
enumerations are stored as the *words the panel's dropdowns show*, not as their indexes: an index ties
|
||||||
|
the file to a declaration order, which is the lesson `math_op_at()` carries, and the words are shared
|
||||||
|
with the panel so the two cannot drift. The channel resolves when the acquired channels exist
|
||||||
|
(`rebuild_trigger_signals()` picks up `App::pending_trigger_channel`, or `apply_trigger_layout()`
|
||||||
|
resolves it itself when they are already there) — a name that resolved to nothing would silently move
|
||||||
|
the trigger to channel 0.
|
||||||
|
- **A session that loaded is what locks the trigger pane.** Locked is the returning user's mode: the
|
||||||
|
trigger pane shows one compact line and offers no control that changes the configuration (the signals
|
||||||
|
have no lock). A *first run* — no session file for that source — never
|
||||||
|
reaches `apply_layout()` and stays in configuration mode, which is what a first run is for. The mode
|
||||||
|
is a mode and not a setting: it is deliberately not persisted, so a scope reopened tomorrow starts
|
||||||
|
where a returning user expects to start. **Dragging a signal onto a plot is not configuration and survives
|
||||||
|
the lock**: it is the signal *badge's* drag (the list's rows are no longer drag sources, and the badges do
|
||||||
|
not follow the lock). `[sigbadges] drag_sources` is the reading, and `drag_sources` on the `signals panel:`
|
||||||
|
line must be 0.
|
||||||
|
- **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 unit renames**
|
||||||
|
(`[[unit]]`, `name` + `unit`, only the overrides; `ScopeModel::set_signal_unit` re-applies them to every CONFIG,
|
||||||
|
and `set_signals()` takes them off the incoming units before the "same stream" comparison so a reconnect still
|
||||||
|
keeps its history), **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**, **the measurements pane**, and **`measure_stats`** — the measurements statistics
|
||||||
|
toggle; written as a bare top-level boolean (`measure_stats = true/false`), defaults to `false`
|
||||||
|
when absent, and **`dock`** -- `LayoutState::dock_shown`, the Dock button's state (the user's last word on it,
|
||||||
|
`App::sidebar_wanted`): a bare top-level boolean written where `side_tab` used to be, default `true`.
|
||||||
|
It is the **only** thing the dock persists -- its 280 px width is fixed, so there is nothing to store --
|
||||||
|
and **the open slide-over page is deliberately not persisted** (a panel that opens by itself at start-up
|
||||||
|
is a surprise, and a page id from another source kind may not exist). The parser ignores unknown keys
|
||||||
|
(its key chain has no erroring final `else`), so a file written before the tabs became a dock
|
||||||
|
(`side_tab = "measure"`, or `side_tab = "source-ao"`) still loads and opens with the dock shown;
|
||||||
|
nothing reads the old key, and `[dock-session]` asserts it. 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".
|
||||||
|
- **A `[[plot]]` table is one *plot* (one cell), and carries `row` and `width` for the horizontal split.**
|
||||||
|
`plots = N` counts plots, as it always did. `row` is the 0-based **figure row** the plot sits in and
|
||||||
|
`width` the cell's share of that row's width (`LayoutState::Row::row`, `::width`); the tables run in
|
||||||
|
the flattened order, so a row's cells are consecutive and left to right. The row's height share
|
||||||
|
`weight` stays per *figure row* but is **written on every cell of it** (so a table read alone is
|
||||||
|
complete) and **read from the row's first cell**; `collect_layout()` takes it from
|
||||||
|
`row_weights()[figure_row_of(plot)]`, not from `row_weights()[plot]` -- the latter is a plot index into
|
||||||
|
a figure-row vector, right until the first split. The file is written `row`, `width`, `weight`,
|
||||||
|
`signals`; a state built with `row < 0` (nothing in the app does) omits `row` and `width`.
|
||||||
|
Three loading rules, all in `apply_layout()` and none of them a refusal, because a session is the
|
||||||
|
user's arrangement and not worth losing to a malformed key:
|
||||||
|
(1) **a file with no `row` loads as one cell per row** -- every session written before the split --
|
||||||
|
with the per-plot `weight`s as the row heights, exactly as it opened before;
|
||||||
|
(2) **a `row` sequence that cannot be a layout loads the same way**: some plot without a `row`, a row
|
||||||
|
number that goes backwards or skips one, a first row that is not 0, more than 4 cells in a row, or
|
||||||
|
more than 8 plots; the signals stay on the plots they were on (`invalid-rows` is the reading);
|
||||||
|
(3) **cell widths are applied after the arrangement**, because `set_cells_per_row()` resets them, and
|
||||||
|
only for rows of two or more cells -- a one-cell row has no widths and its `width` is ignored.
|
||||||
|
Both new keys are parsed with the file's other forgiving rules: `row` must be an integer >= 0 and
|
||||||
|
`width` a number > 0, anything else leaves the default. A save after loading a pre-split file writes
|
||||||
|
the `row`/`width` keys, which is how an old session is upgraded without anyone asking.
|
||||||
|
- **Measurement options persist under *named* keys -- `threshold`, `threshold2`, `noise` and `hf` -- so a
|
||||||
|
file written before them loads at the defaults**, which are exactly the behaviour it was written against:
|
||||||
|
`noise = 10` is the
|
||||||
|
hysteresis band the app has always counted with, `hf = 0` is no filter, and a file with no
|
||||||
|
`threshold2` -- every file written before `between` existed -- leaves that measurement at
|
||||||
|
`threshold2 = 75`, which is the value the dialog offers a new one. The *pair* is stored as written
|
||||||
|
rather than as ordered: `measure_level_pcts()` is what puts a reversed pair in order on read, so a
|
||||||
|
file records what the user typed and a measurement file round-trips unchanged.
|
||||||
|
- **The math node string is thirteen pipe fields, and the reader accepts twelve *or* thirteen.** `med_n` is
|
||||||
|
the thirteenth, after `y`; a twelve-field node is a file written before the de-noise node existed, and its
|
||||||
|
`med_n` stays at the struct's default -- which is what such a file meant. Field 0 is the op as a
|
||||||
|
`math_op_at()` index, which is why `MathOp` may only be *appended* to.
|
||||||
|
- **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 MDF export is MDF 3.30 written by hand, and its layout is the reader's, not the
|
||||||
|
standard's prose.** No dependency is added: MF4 is HDF5 and would need a library, while MF3 is a
|
||||||
|
handful of fixed-size blocks, so `write_mdf()` spells them out. The field list comes from
|
||||||
|
`asammdf` 8.8.27 (`blocks/v2_v3_blocks.py`, `blocks/mdf_v3.py`, which parses MF3 by explicit
|
||||||
|
offsets) and was confirmed byte for byte against a known-good 3.30 file asammdf wrote (one
|
||||||
|
group, a `time` master and two `V` channels). The shape, in file order: the **ID block** is 64
|
||||||
|
bytes with *no id/length header* (`"MDF 3.30"`, byte order, version `330`) — a writer that
|
||||||
|
emits `ID` + size + links at offset 0 produces something no reader accepts, since the first real
|
||||||
|
block starts at 64; the **HD block** is 208 (164 common + 44 of the 3.2-era time fields) and
|
||||||
|
points at the data group; a **TX** comment follows it; then the **samples**; the **DG** is 28; a
|
||||||
|
**CC** (conversion, 46 — a `NONE` conversion is the common fields alone) then a **CN** (channel,
|
||||||
|
228) precede each channel; and the **CG** (30) is last. Address fields are absolute file offsets
|
||||||
|
written *forward*, so the whole address map is computed before a byte is written, and every
|
||||||
|
block length is checked after it is built: a field at the wrong width does not fail the write, it
|
||||||
|
leaves the reader in the middle of the next block.
|
||||||
|
- **The data group's data address holds the raw records, with no `DT` header** — which is what
|
||||||
|
asammdf's *writer* does and what its *reader* assumes (`DataBlock` is documented as "a pseudo
|
||||||
|
block not defined by the MDF 3 standard"). This is the one place the file follows the reader
|
||||||
|
rather than a narrow reading of the standard, and it is the assumption ScryLab compatibility
|
||||||
|
rests on: a file with a `DT` block there would have its header read as sample bytes.
|
||||||
|
- **In MDF the master is `abs_seconds`, named `time`, and the relative axis is a channel
|
||||||
|
`time_relative`** — the same two numbers the `.npy`/`.csv` table holds, in the other roles. A
|
||||||
|
master off the relative column is 0 at a different sample in each display mode, so it would put a
|
||||||
|
live export and a held event's export on two timelines, which is the one thing the second column
|
||||||
|
exists to avoid; both carry the unit `s`, and the signal columns follow in the `.npy`/`.csv`
|
||||||
|
order. **The unit** is the client's CONFIG unit (a math channel's own), and it is written into
|
||||||
|
the CC block because that is the only place MDF 3 keeps one — and the place ScryLab reads a
|
||||||
|
signal's unit from. `ExportTable::Column` gained the field for it; the two older writers ignore
|
||||||
|
it, and their bytes are unchanged. **The names** are sanitised and de-duplicated against each
|
||||||
|
other *and* against the two reserved names before being cut to 31 bytes (the CN name field is 32
|
||||||
|
with its NUL): ScryLab imports only the **first** occurrence of a channel name, so a cut made
|
||||||
|
before de-duplication could silently drop a signal. `write_export()` is the one place the
|
||||||
|
extension picks the writer (`.csv`, `.mf3`/`.mdf`, anything else `.npy`), and both the dialog and
|
||||||
|
the self-check's export probe come through it so the two cannot offer different formats.
|
||||||
|
- **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.
|
||||||
|
- **It refuses again while `replaying` is set: a file view is not the source's session.** Same
|
||||||
|
argument, different cause — this view configured nothing about the stream. A plain replay leaves
|
||||||
|
the rows where the user put them, so its save would merely be pointless; a **group** rebinds every
|
||||||
|
slot to its own renamed channels and the save wrote `signals = ["square (A)", …]` into the file.
|
||||||
|
Nothing noticed until the way back: *Go live* re-applies the session, those names match no live
|
||||||
|
signal, so nothing was enabled and **the live view came back with no channels at all** — on the
|
||||||
|
window the poisoned file carried, which is also how it swallowed the window restore below it. The
|
||||||
|
reading is `[events] live restored (after a group)`: the symptom was a stuck `pending=1` beside
|
||||||
|
`channels=0`, because the restore waits for a channel list that was never going to arrive. The
|
||||||
|
explicit *Save as* is deliberately **not** guarded: that one is a user's own answer to "save this
|
||||||
|
view", and its dialog reports the outcome.
|
||||||
|
|
||||||
|
## A configuration is a named session, and a source may hold several
|
||||||
|
|
||||||
|
One source, several arrangements: the same streamer serves different experiments, and retyping a
|
||||||
|
layout to look at the second one is how a session gets lost. Storage is therefore a **directory
|
||||||
|
per source** — `~/.config/udpscope/<host>_<port>/` — holding one `<name>.toml` per configuration
|
||||||
|
plus an `active` file naming the one the source is on. `configuration_path()` is the only
|
||||||
|
expression of the scheme, and the name is `sanitize()`d exactly as the host is, so a name typed
|
||||||
|
with a slash cannot escape the directory.
|
||||||
|
|
||||||
|
A configuration carries the whole session *and* the settings that can only be decided before the
|
||||||
|
first socket: `buffer_samples`, `relative_time`, `capture_packets`. The startup chooser applies them
|
||||||
|
(`apply_source()`), which works because `choose_source()` runs before `ScopeModel` is constructed
|
||||||
|
— the one moment a buffer size can be applied at all. Switching configurations inside a running
|
||||||
|
app applies what can change live and says in the status line that the rest takes effect on the
|
||||||
|
next start; pretending otherwise would be a configuration that half-applies.
|
||||||
|
|
||||||
|
A configuration's *startup* settings reach the model from two directions, and both are needed:
|
||||||
|
`apply_source()` for a source picked in the chooser, and `apply_stored_settings()` for a source
|
||||||
|
named with `--server`, which never reaches the chooser at all. Without the second, a saved host's
|
||||||
|
buffer size, clock and capture ring were silently ignored — the layout still applied, because the
|
||||||
|
app loads that separately once a CONFIG names the signals — so an edit to any of the three read as
|
||||||
|
one that never took effect. **A flag the user typed wins over the file** (`--buffer-samples`,
|
||||||
|
`--relative-time`, `--capture-packets`): the file fills in what the command line did not say, and
|
||||||
|
`Config::buffer_samples_specified` is what tells "not asked" from "asked for the default".
|
||||||
|
|
||||||
|
The `active` file is written on every save and by the chooser's pick, so "which configuration was
|
||||||
|
I on" survives a restart. A session written before configurations existed is **imported once** as
|
||||||
|
the configuration named `default`, and the old file is renamed to `<host>_<port>.toml.imported`
|
||||||
|
rather than deleted: an import that cannot be undone by hand is a migration nobody can back out
|
||||||
|
of.
|
||||||
|
|
||||||
|
Nothing is lost by switching: the dialog saves the current session *before* loading the picked
|
||||||
|
one, which is the only ordering in which a switch cannot lose what it switches from.
|
||||||
|
|
||||||
|
- **The event cache is keyed on the source *and its active configuration*, and the key moves
|
||||||
|
with the switch.** An event's trigger, its channel set and its units are the configuration's,
|
||||||
|
so the directory is `<base>/<host>-<port>/<configuration>/` (`EventStore`'s third constructor
|
||||||
|
argument) and `retarget_event_store()` -- called next to every `App::config_name` write and
|
||||||
|
resolving the name from `active_configuration()` when it is empty -- re-derives it, refreshing
|
||||||
|
both views of the list only when the directory changed. A store built without a configuration
|
||||||
|
keeps the legacy `<base>/<source>/` path, and the **first construction under a configuration
|
||||||
|
adopts the loose event files it finds there** (`*.npy`/`*.txt`/`*.bmp`, moved into the
|
||||||
|
configuration's folder): an upgrade keeps the events it had. The key is a *sanitized* path
|
||||||
|
component, the same rule the configuration files use, so a name typed with a slash cannot
|
||||||
|
leave the directory.
|
||||||
|
|
||||||
|
- **The session remembers extra windows; a launch never opens them.** Each scope or detached
|
||||||
|
window is a `[[window]]` block (`name`, `detached`, and its own `[[window.plot]]` rows -- the
|
||||||
|
same keys the main `[[plot]]` rows carry), written from the open windows' present arrangement
|
||||||
|
plus the ones already closed (`collect_layout()`). Loading puts **all** of them in
|
||||||
|
`App::closed_windows`, never into `app.scopes`: the overflow menu's *Reopen closed windows (N)*
|
||||||
|
is the only path that opens them (`open_scope_window_from_state()`, the one constructor every
|
||||||
|
window uses, then its rows). Closing a window records it (by name, replaced, so close/reopen
|
||||||
|
cycles do not multiply entries); **attaching** records nothing -- its figure is on the main
|
||||||
|
window again -- and a detached figure survives a quit through its window's entry instead of
|
||||||
|
being lost from the main layout.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# 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.
|
||||||
|
- **With nothing bound, `set_time_window()` frames `{-window, 0}` and never `{0, window}`.** Under
|
||||||
|
a following x group a range states two things — its span, and *through its maximum* where the
|
||||||
|
window sits behind the live edge — so a maximum **at** the edge is what makes the offset zero.
|
||||||
|
When there is a live edge to read that is `{newest - window, newest}`; when there is not, the
|
||||||
|
edge the live view would put at x = 0 is 0, so the equivalent statement is `{-window, 0}`.
|
||||||
|
Asking for `{0, window}` there is an offset of a *whole window*. This is not a rare fallback:
|
||||||
|
"no samples bound" is the state every structural change leaves behind, and
|
||||||
|
`apply_time_window()` re-plans the rings and *then* calls this, so it is the axis a mode change
|
||||||
|
is drawn with until the first sample lands. With `{0, window}` the two or three frames after
|
||||||
|
every event or waveform open drew the time base one screen to the left of where the following
|
||||||
|
frames drew it — a trace that jumps a screen sideways and comes back, which is what "opening
|
||||||
|
events or even waveform sometime result in data flickering (like time base) and is unusable"
|
||||||
|
was. The reading is the dump's traceless paint counts: `mode=live` 38 → 0 and `mode=waiting`
|
||||||
|
2 → 40, the latter being the point — a waiting view now paints its empty axis in the *same*
|
||||||
|
place every frame instead of twitching into a framed one.
|
||||||
|
- **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 a plot
|
||||||
|
whose x strip draws labels (`plot::x_strip_has_labels(x_strip_mode())`: the bottom row, and any row above it whose
|
||||||
|
columns differ from the row below's) carries one, every other plot 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_` (it scans **every**
|
||||||
|
row's member, in order, since a labelled strip on a row other than the first takes x gestures too), 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.
|
||||||
|
- **Relative time is decided by the newest statement, not by the default.** `Config::relative_time_specified`
|
||||||
|
is set by `--relative-time` and by the preferences — which the source chooser can reach before the run
|
||||||
|
is planned, so that is the one place a preference still changes *this* run. Both the list's own copy
|
||||||
|
(`apply_source()`) and a source's stored configuration (`apply_stored_settings()`) only fill in when
|
||||||
|
nothing newer was said. Without the marker, clicking a source row after applying the preference
|
||||||
|
silently reverted it — `[sources] picked ... relative=0`, with the dialog having just written `1`.
|
||||||
@@ -0,0 +1,639 @@
|
|||||||
|
# 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.
|
||||||
|
- **The compact pane's arm control is built in the heading, and *one* function states both arm
|
||||||
|
buttons.** Arming is a runtime decision and the locked pane is exactly where it is made, so the
|
||||||
|
control cannot live in the body — that is the part which goes away. `sync_arm_buttons()` is what
|
||||||
|
states the label, the sensitivity and the visibility of both buttons, and it is called from the four
|
||||||
|
places any of its three inputs can change (the click, the toolbar toggle, the curator's re-read,
|
||||||
|
`apply_pane_lock()`). Visibility is `enabled && locked`: with trigger mode off the line beside it
|
||||||
|
already says "stream (trigger off)" and there is no engine to arm, and with the pane open the body's
|
||||||
|
button is the one on screen — **two arm buttons at once would be two controls for one state**, which
|
||||||
|
is why the probe reads the body's allocation as `-1` while the compact one is up.
|
||||||
|
**The same function states the Force button beside the arm one, and the Force button is in the body
|
||||||
|
only.** Its sensitivity is the same `enabled` (a trigger that is off has no engine to force) and it
|
||||||
|
is stated in `sync_arm_buttons()` for the same reason the pair above is stated there — one place, so
|
||||||
|
nothing can drift — but it is *not* armed-gated: in Single mode the previous acquisition leaves the
|
||||||
|
engine disarmed, which is exactly when a user reaches for Force again, so a button that greyed out
|
||||||
|
with the engine's armed state would be unavailable for the one thing it is for. It stays in the body
|
||||||
|
because the heading's single control is the runtime *state* a locked pane exists to change, while a
|
||||||
|
force is an action whose subject — the condition, the level, the pre/post split and the mode that
|
||||||
|
decides whether one acquisition is all of it — is in the body; a second Force in the heading would
|
||||||
|
be a second control for one action whose settings are off screen.
|
||||||
|
- **A widget's allocation is not readable in the tick it is first shown.** The compact arm control is
|
||||||
|
created hidden and shown for the first time inside a signal handler, so `gtk_widget_get_width()` in
|
||||||
|
that same tick reads `0` while `gtk_widget_is_visible()` already says `1` — and its sibling's stale
|
||||||
|
allocation (270 px, from when the *body* was still on screen) reads as a live one, because
|
||||||
|
`gtk_widget_get_visible()` is the widget's *own* flag and stays true under a hidden parent. The width
|
||||||
|
only becomes the proof that a control is on the surface **one frame later**, which is why the probe
|
||||||
|
has a `compact_arm_step()` of its own; and the control to compare against must be measured with
|
||||||
|
`is_visible()` or not at all.
|
||||||
|
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
|
||||||
|
signal nobody acquires can never fire, so selecting/arming enables it. **And the signal is resolved
|
||||||
|
*through* the channel, never taken as one.** `TriggerConfig::channel` indexes `channels()` (the
|
||||||
|
acquired ones) while `ScopeModel::set_signal_enabled()` indexes the stream's `signals()`, and the
|
||||||
|
two are not the same index: with one signal acquired, channel 0 *is* that signal while signal 0 is
|
||||||
|
the stream's own clock. Passing one for the other switched on the time base, left the signal the
|
||||||
|
trigger watches exactly as it was — and, because a signal the panel was not showing *is* a change,
|
||||||
|
rebuilt the buffer under the click and dropped its history, which is precisely the pre-trigger
|
||||||
|
window an arming, and now a force, was about to capture. `trigger_signal_index()` is the one
|
||||||
|
resolution and both buttons go through 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.**
|
||||||
|
- **The engine identifies its channel by *name*, never by index.** `TriggerEngine::configure()`
|
||||||
|
drops an open capture when the trigger moves to a different channel, and it is re-called from
|
||||||
|
`Acquisition::on_data()` on **every packet** out of `model_.trigger()`. `TriggerConfig::channel`
|
||||||
|
is an index into `channels()`, and that index is not stable: every rebuild of the channel list
|
||||||
|
renumbers it — enabling a signal that sorts before the trigger's own is enough — and
|
||||||
|
**`ScopeModel::rebuild_channels()` re-resolves the trigger by name and writes the *new* index
|
||||||
|
back, for every caller.** It lives there and not at the call sites because the function *is* the
|
||||||
|
renumbering, and a caller that forgot is a trigger that silently stops: the name-based restore
|
||||||
|
used to be in `rebuild_enabled_locked()` alone, so adding or removing a math channel went through
|
||||||
|
`set_math_channels()`'s structural path with no restore at all, `rebuild_locked()` clamped the
|
||||||
|
now-wrong index to `-1`, and `process()` returns at once on a channel of `-1` while the panel went
|
||||||
|
on naming the channel. That is the second half of the "it sometimes does not trigger, even with
|
||||||
|
the trigger on a non-math channel" report. Keying the reset on the index made any such rebuild
|
||||||
|
abandon the capture **and
|
||||||
|
set no hold-off**, so the next edge inside the post window became t0: that is the "the trigger
|
||||||
|
re-triggers during a capture and the hold-off does not apply" defect, and it needed no glitch
|
||||||
|
and no user action beyond enabling a channel. The name changes when the source genuinely
|
||||||
|
changes, so a deliberate re-point still resets. The rebuild also replaces the `BufferStore`, so
|
||||||
|
the surviving capture's pre side is short and the snapshot reports its own `pre_s` — which may
|
||||||
|
be **negative** when the new ring starts after the edge. That is a faithful reading, not a
|
||||||
|
defect: `[-pre_s, post_s]` still describes the samples the snapshot holds. The guard is
|
||||||
|
`UDPSCOPE_PROBE_TRIGRENUM_MS`, whose `t0_held=1` is the assertion.
|
||||||
|
- **`EventCondition` carries the trigger's own rejection: `noise_band` and `hf_fc`.** Both are in the
|
||||||
|
struct's `operator==`, so a change to either *is* a change to the condition. `EventDetector::configure()`
|
||||||
|
takes `dt` and early-returns only when the condition **and** the interval are unchanged -- it runs on
|
||||||
|
*every packet*, so a configure that reset unconditionally would restart the machine before any pulse could
|
||||||
|
close. `dt` is `Partition::slot_dt()`, the *stored* interval and not the CONFIG's nominal rate, and comes
|
||||||
|
from exactly three call sites (`acquisition.cpp`, `ui_trigger.cpp`, `ui_probe.cpp`); a zero there leaves
|
||||||
|
the reject's coefficient at 1, which is a pass-through.
|
||||||
|
- **A crossing is a change between two *decided* sides, never a change from the undecided one.** The band
|
||||||
|
makes each sample decide above / below / undecided, and an edge is counted only when the signal has reached
|
||||||
|
the far threshold of the side it is leaving. Seeding the previous side from `0` -- the undecided code --
|
||||||
|
fires on a signal that starts *inside* the band and leaves it without ever reaching the level, an event the
|
||||||
|
trigger is not supposed to see.
|
||||||
|
**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.
|
||||||
|
**What a gesture inside the pin may *not* move is a measurement**, and it is not this function
|
||||||
|
that holds that: `pinned_range()` is the one expression of the pin's extent, and
|
||||||
|
`update_measures()` measures *it* rather than the viewport while the view is pinned, so a pan
|
||||||
|
or a zoom on a still frame changes no value and spends no recompute (see
|
||||||
|
[invariants-measurements.md](invariants-measurements.md); `[measurepin-probe]` is the reading,
|
||||||
|
`pin=`/`for=` in `[measure-trace]` the field to watch). The pin is therefore load-bearing twice
|
||||||
|
over, and a change to what `pinned_range()` says changes both the framing and the numbers.
|
||||||
|
**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.
|
||||||
|
- **An opened file is a pinned view too, and *how* it is pinned matters as much as that it is.** The
|
||||||
|
file view states its pin from the ring's own stamps every frame, and it used to do that with
|
||||||
|
`pin_event_window()` -- which writes the group's range as well as the pin's numbers -- so a box zoom
|
||||||
|
drew its frame and changed nothing: the controller's `set_x_range` went onto the *member*, the next
|
||||||
|
frame's re-statement put the group's range back, and the branch it fell into (`clamp_to_data()`,
|
||||||
|
because a file is neither `event_held` nor `Paused` nor `capturing`) never adopts a member into a
|
||||||
|
group that is not following. That is the wheel-in-single-trigger defect again, in the one mode nobody
|
||||||
|
re-tested. Two things hold it now: the branch is `Paused || capturing || rows->window_pinned()`, so a
|
||||||
|
pinned view is always clamped by `hold_pinned_window()`; and the file's per-frame statement uses
|
||||||
|
`set_data_pin()` -- the pin's numbers -- unless `pinned_window_is()` says the pin is a *different*
|
||||||
|
window, which is a re-open with a new extent and is a new event, stated in full. `[boxzoom-probe]`'s
|
||||||
|
`zoomed=` is the guard, and it is a reading to take in **both** modes: live and file must both read
|
||||||
|
`1`.
|
||||||
|
- **`set_data_pin()` takes a *range*; `pin_event_window()` and `pinned_window_is()` take the pre/post
|
||||||
|
*split*.** The three are used in one breath by the file view's statement and by the held event's, and
|
||||||
|
the two forms are not interchangeable: for data over `[lo, hi]` it is `set_data_pin(lo, hi)` against
|
||||||
|
`pin_event_window(-lo, hi)`. Handing the split to `set_data_pin()` stores the pin as `[lo, -lo]`-ish,
|
||||||
|
so the next frame's `pinned_window_is()` reads false, the pin is stated in full again, and the view
|
||||||
|
alternates between the file's span and the corrupted pin at the frame rate -- the flicker in which no
|
||||||
|
interaction survives, because every second frame rewrites the group's range. It is invisible on a file
|
||||||
|
whose stamps are all positive (the corrupted pin is then *wider* than the data and
|
||||||
|
`hold_pinned_window()` never clamps) and on one that ends at 0 (the pin inverts, and the clamp returns
|
||||||
|
early), which is why it reached a user on a recording spanning `[-4..16]` and nothing before it.
|
||||||
|
`[drop-probe]`'s `repins=` is the reading: **2 for a whole run**, not one per frame.
|
||||||
|
- **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 pause reads the display offset *before* it sets the pause state, and a pause taken on a
|
||||||
|
held event depends on that order.** `on_pause_toggled()` reads the row's axis and
|
||||||
|
`stream_at_x0()`, cuts `view + offset` out of the ring, and hands the same offset to the
|
||||||
|
snapshot as its `x_offset`. `stream_at_x0()` follows the *display*: the moment `paused` is true
|
||||||
|
with no pause view published yet it answers `-display_epoch()`, which on a `Held` view is **0**
|
||||||
|
-- so the cut searched the ring at the event's edge-relative x, found nothing, and froze an
|
||||||
|
**empty** picture: `ev=0` on the probe's line, every measurement `--` and every ruler gone,
|
||||||
|
which is a much louder symptom than the empty margin above and reaches a user the same way
|
||||||
|
(pause while an event is on screen). Read first, it is the offset the axis is actually
|
||||||
|
displaying, which is what the whole scheme requires. The live case is unchanged -- there the
|
||||||
|
epoch does not depend on `paused()`, which is why the pause probe, which pauses a *live* view,
|
||||||
|
never saw it.
|
||||||
|
- **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()`.
|
||||||
|
- **Force is a synthetic edge, and the latch is still the only publisher.** The trigger panel's
|
||||||
|
Force button opens one capture at the *present* moment through `TriggerEngine::force()`: the
|
||||||
|
newest stored stamp becomes the event's own time and the engine enters the same pending state an
|
||||||
|
edge leaves it in, so the ordinary latch cuts, publishes, counts and frames the acquisition. A
|
||||||
|
forced acquisition therefore obeys every rule a latched one does — the axis pinned from the
|
||||||
|
snapshot's own stamps, a `pre_s` shorter than the request when the ring does not reach back that
|
||||||
|
far, and the frame that frames it being the one that consumes its `event_seq()` — and the button
|
||||||
|
owns no window, no publish and no framing of its own: **the event it publishes is cut by the same
|
||||||
|
code as a latched one.** Two things it deliberately does *not* do, both of them choices:
|
||||||
|
- **It skips the pre-window gate.** `force()` does not test `event_t - oldest_s_ >= pre_seconds_`:
|
||||||
|
"capture now" is a request for the history that *exists*, and refusing it because the ring is
|
||||||
|
young would refuse the one thing the button is for. The snapshot reports its own `pre_s`, and the
|
||||||
|
pin follows it — which is also how the pin gets *read*: with a request the ring cannot satisfy (a
|
||||||
|
100 s window at 99 % pre) the axis must come out a few seconds wide, where a pin stated from the
|
||||||
|
request would come out a hundred.
|
||||||
|
- **It does not restart a capture already filling.** That capture *is* the acquisition being asked
|
||||||
|
for: the display is already showing it fill (`live(capturing)`), and restarting it would throw
|
||||||
|
away the post window it has collected and move the marker to the press. The click is refused, and
|
||||||
|
named on stderr, rather than silently reinterpreted.
|
||||||
|
What the trigger *is* afterwards is still the mode's decision, and it is made where it always was —
|
||||||
|
in the latch: Normal leaves the engine armed with the latch's hold-off, Single disarms it. One
|
||||||
|
acquisition, whether an edge produced it or the button.
|
||||||
|
- **The Force request is written under the model lock and consumed at the end of a packet.** It is
|
||||||
|
one atomic flag on `Acquisition` (`request_force()`), read with `exchange` after the channel loop
|
||||||
|
and before the `trigger_pending()`/`capture_edge()` publish — after the loop because the cut
|
||||||
|
`publish_event()` is about to make clamps to every value ring's `produced()`, so the newest sample
|
||||||
|
of every channel has to be in by then, and before the publish so the capture the click opened is
|
||||||
|
reported by the same packet that opened it. The *write* belongs under `ScopeModel::mutex()` and in
|
||||||
|
the same critical section as the `armed` flag the click also sets: the producer holds that lock for
|
||||||
|
a whole packet, reading the panel's `armed` at the top of one and the request at the bottom, so a
|
||||||
|
request written in between would be read by a packet whose engine was still disarmed and then
|
||||||
|
dropped — and dropped is right, because a momentary request must not be replayed later on a trigger
|
||||||
|
the user has stopped watching. With **no channel resolved** (no signal acquired) the click is
|
||||||
|
refused in the handler and not left to the producer: `on_data()` returns at once for a model with
|
||||||
|
no *streamed* channel, so a request parked behind that return would fire on the first signal
|
||||||
|
switched on afterwards.
|
||||||
|
- **The trigger engine is not fed a reserved hole's samples.** A NaN comparison is false anyway,
|
||||||
|
so the engine would not latch; but the real reason the samples are withheld is the other one:
|
||||||
|
feeding them would advance the hold-off clock across time nothing was sampled in. The probe
|
||||||
|
asserts `trigger_in_hole=0` rather than relying on NaN's arithmetic properties.
|
||||||
|
- **`EventDetector` owns every condition shape and holds no arming state; `TriggerEngine` owns
|
||||||
|
the arming, the hold-off and the post window and holds no condition.** The split is what lets
|
||||||
|
the buffer search ask the trigger's exact question without owning a trigger, and it is only
|
||||||
|
true as long as neither half leaks into the other: a detector that knew about the hold-off
|
||||||
|
would answer a search differently from the live trigger, which is the one thing the
|
||||||
|
`agree=1` cross-check in `[search-probe]` exists to catch.
|
||||||
|
- **The detector is fed on *every* sample, including during the hold-off and during a capture.**
|
||||||
|
A pulse width, a window excursion and a dropout timeout are all *stateful over several
|
||||||
|
samples*: skipping the feed while the engine is busy loses the opening edge, and the condition
|
||||||
|
then measures from whatever sample the engine happened to resume on. The hold-off is applied
|
||||||
|
to the detector's *answer*, not to its input — `process()` feeds first and tests
|
||||||
|
`now_s < holdoff_until_s_` afterwards.
|
||||||
|
- **An event's time is the detector's, not the sample's.** A pulse-width event is detected at
|
||||||
|
the closing edge but could in principle be timed at the opening one, and a dropout is detected
|
||||||
|
one sample after the timeout expires. `feed()` therefore returns the event's *own* time and
|
||||||
|
`process()` uses that for `last_trigger_s_`, `pending_start_s_` **and** the pre-window gate —
|
||||||
|
all three, or the marker, the cut and the gate would disagree with each other by the width of
|
||||||
|
a pulse.
|
||||||
|
- **The trigger's whole input is the stored clock, so a clock that stops is a trigger that never
|
||||||
|
fires — and *that* is what a blank trigger-mode display is.** `Acquisition` feeds
|
||||||
|
`TriggerEngine::process(value, rel)` with the same `rel` it stamps the ring with, and the engine
|
||||||
|
has no other clock: the pre-window gate measures the event against the retained history
|
||||||
|
(`event_t - oldest_s_ >= pre_seconds_`), the post window counts *stream seconds* past the edge,
|
||||||
|
and `holdoff_until_s_` is set from `now_s` at the arm. A timeline that stands still therefore
|
||||||
|
reads as `history=0.0000/1.0000 s` for ever, on every line of `trig engine:`, no matter how many
|
||||||
|
samples arrive — and with no latch, `display_mode()` never leaves `Waiting`, whose bind is
|
||||||
|
*nothing* by design, so the plot is blank and the other traces are blank with it (the renderer's
|
||||||
|
bound window is empty too, so the same emptiness shows with the trigger off). Nothing about that
|
||||||
|
picture says "clock": it is "in trigger mode, with a math channel enabled, the data disappears",
|
||||||
|
which is how it reached a user. The reading is the `trig engine:` line's own pair — **`n` climbing
|
||||||
|
while `ring=[a..a]` stands still** — and the one-shot
|
||||||
|
`[udpscope] warning: the clock stalled ...` that `on_data()` prints at the first whole frame whose
|
||||||
|
stamps are all behind the clock. See
|
||||||
|
[invariants-data-path.md](invariants-data-path.md) for the two entries (a plan whose interval
|
||||||
|
moved under the stream; a frame accepted into a model with no streamed channel) and for why the
|
||||||
|
relative clock is accumulated in seconds.
|
||||||
|
|
||||||
|
- **Persistence is the older acquisitions drawn *over* the current one, and "a layer is not a
|
||||||
|
signal" is what keeps them from becoming one.** Trigger mode may keep the last `N`
|
||||||
|
acquisitions -- `TriggerConfig::persist_depth`, the panel's *persistence* spin, 1 = the event
|
||||||
|
on screen alone -- and draw the older ones dimmer, which is the whole feature. The machinery is
|
||||||
|
the layer machinery the accumulate path already has: one **layer** per (signal, age), in the
|
||||||
|
same trace slot the accumulate path puts its own extra events in, named with the same suffix
|
||||||
|
vocabulary (` (A)`, ` (B)`, ` #4`), so `layer_base_name()` gives the base signal back and the
|
||||||
|
signal list, the colour resolver, the measurements and the layout writer all keep seeing **one**
|
||||||
|
signal where the plot draws several traces of it. Four consequences, each of which is a rule:
|
||||||
|
* **A layer slot holds no channel.** `RowSet::bind()` resolves a layer's *base* signal (it must
|
||||||
|
still be acquired -- switching a signal off takes its traces off every plot, layers included)
|
||||||
|
and then finds the channel **by name inside the snapshot** (`EventSnapshot::channel_names`),
|
||||||
|
because the channel list is renumbered by any structural change since that acquisition was
|
||||||
|
cut -- the rule `TriggerEngine` already follows, for the same reason. It leaves
|
||||||
|
`Row::channel[]` at `-1`, which is what makes every reader that asks "is this slot a trace of
|
||||||
|
a signal" skip it: the trace counts, the cursors pane's `mix` mask, the measurement marks, the
|
||||||
|
dump's per-slot lines, `collect_layout()`. A persistence layer is therefore invisible to the
|
||||||
|
signal list, the calibration table and the measurements by construction and not by a check;
|
||||||
|
the *measurements* go on describing the current frame because they resolve a channel from the
|
||||||
|
signal name the user configured, and `sine (A)` is not a signal anybody can configure.
|
||||||
|
* **Its brightness is a property of the slot, and it is applied where the trace is created.**
|
||||||
|
`Row::slot_alpha` is `RowSet::persist_alpha(age)` -- **half per acquisition**, so the schedule
|
||||||
|
is 1, 0.5, 0.25, 0.125, ... and the seventh layer is 0.8 %, i.e. gone; it does *not* depend on
|
||||||
|
the depth, so raising N adds dimmer acquisitions instead of restating the ones already on
|
||||||
|
screen. `install()` mixes `slot_alpha * trace_alpha_` into the trace's colour, because a
|
||||||
|
`Plot` fixes a trace's colour **at `add_trace()`** and a brightness nobody can set later is a
|
||||||
|
brightness that has to be a rebuild. Two things follow: a per-layer alpha is impossible
|
||||||
|
without a per-slot one, and **`set_trace_alpha()` is finally read** -- it was set by the
|
||||||
|
accumulate path and read by nothing, so an accumulation's "translucent, so agreement is
|
||||||
|
brightness" was opaque in fact while the docs claimed otherwise.
|
||||||
|
* **The dimmest trace is created first.** A `Plot` draws its traces in the order they were
|
||||||
|
created and cannot reorder them, so `install()` creates them sorted by brightness and the
|
||||||
|
*newest* acquisition is the ink on top. A layer drawn over the event would take its colour out
|
||||||
|
of the event's own trace wherever the two agree -- the opposite of what a persistence display
|
||||||
|
shows.
|
||||||
|
* **Every slot the plan does not fill is freed, and the plan does not depend on the data.**
|
||||||
|
`RowSet::sync_layers()` (called from the tick, before the frame's `bind()`) re-plans one layer
|
||||||
|
per (signal, age) into the row's free slots, *layer by layer* -- age 1 for every signal first,
|
||||||
|
then age 2, as the accumulate path does -- so a row that runs out of room draws the sharper
|
||||||
|
layer for each of its signals and stops. The plan is a function of the rows, the depth and
|
||||||
|
nothing else, so a **latch costs no rebuild**: the slot names and ages stay put and only the
|
||||||
|
snapshot behind them changes.
|
||||||
|
- **An older acquisition is *drawn*, and it may not move anything.** The current event is the one
|
||||||
|
the axis is pinned to, and persistence must not disturb that, so `RowSet::event_data_window()`
|
||||||
|
-- the union the pin is re-stated from every frame while an event is held -- **skips layer
|
||||||
|
slots**: a wider older acquisition would otherwise re-pin the axis, and with it the window a
|
||||||
|
pinned measurement reads, which is a *measurement* moving because a display option was turned
|
||||||
|
up. The layers are bound only in `Held` and `Capturing` (a live view has no current event to be
|
||||||
|
the newest of them, a `Waiting` display is blank by design, and a pause view's stamps are on the
|
||||||
|
live window's clock, not on an edge): in `Waiting` and `Live` the layer slots are planned but
|
||||||
|
bound to nothing, and a trace with no series is not drawn and not listed in the legend. The one
|
||||||
|
thing they do share with the signal is the **row's y axis**, so the y auto-fit sees them -- a
|
||||||
|
y axis that ignored the traces it draws would clip the excursion the display exists to show.
|
||||||
|
- **A retained acquisition is a whole window copy, so the depth is capped by bytes and by what a
|
||||||
|
row can draw.** `publish_event()` moves the outgoing `event()` into the retention before it
|
||||||
|
publishes the new one: `ScopeModel::persist_event(age)` is the event `age` latches back, one
|
||||||
|
atomic `shared_ptr` slot per age, read by the tick without a lock exactly as `event()` is.
|
||||||
|
`snapshot_bytes()` is the honest cost of one -- both stamp arrays, every channel's `f32` samples
|
||||||
|
and the LOD pyramids built for them, i.e. ~4.5 bytes per sample per channel plus 8 per sample of
|
||||||
|
clock -- and `prune_persist_locked()` cuts the set to **both** bounds: the depth, and
|
||||||
|
`persist_budget_bytes()`, which is the planned ring bytes (`Partition::bytes()`), because persistence is a copy
|
||||||
|
*of* what the rings hold and holding as much again is the honest ceiling for a display of it.
|
||||||
|
`kMaxPersistDepth` is 8 for the different reason the panel needs one: the layer there is already
|
||||||
|
invisible (0.8 %) and seven is all the free slots a one-signal row has. The depth is not the only
|
||||||
|
thing that limits what is *drawn* -- a row of `S` signals with 8 slots shows
|
||||||
|
`floor((8 - S) / S)` ages of each, so the default four-signal row shows one older acquisition
|
||||||
|
and a row per signal shows all seven. The two shortfalls are **separate readings**, and a caller
|
||||||
|
that conflated them would report a shrunken display as complete: a *slot* shortfall is
|
||||||
|
`placed < want` (what `RowSet::sync_layers()` returns against what the depth asked for), while a
|
||||||
|
*budget* shortfall is a `kept` below the depth plus the count `persist_dropped()` carries -- and in
|
||||||
|
that case the layer slots **exist and draw nothing at all**, so `layers=`/`placed=` alone say
|
||||||
|
nothing is wrong. The panel's notice therefore names both numbers -- `D of W layers fit, K of A
|
||||||
|
older acquisitions retained` -- because a user who asked for eight layers and sees two has no other
|
||||||
|
way to tell "the display is deliberately shallower" from "the feature is broken".
|
||||||
|
- **Depth 1 is not a mode, it is the *absence* of one.** `persist_depth = 1` retains nothing at
|
||||||
|
all -- no copy, no layer, no rebuild -- and `set_persist_depth()` prunes the set **at once**
|
||||||
|
rather than at the next latch, because lowering the depth is a user asking to stop paying, and a
|
||||||
|
trigger that has stopped firing never reaches another publish. A depth change is therefore also
|
||||||
|
how the memory comes back.
|
||||||
|
|
||||||
|
- **The phosphor density is the *trace*, and that is a different display from the layers above, not a
|
||||||
|
mode of them.** The trigger panel's `Advanced` block offers both, and the words matter: *multiple
|
||||||
|
events* is `persist_depth` (N retained acquisitions, N traces, dimmer with age) while *persistence*
|
||||||
|
is `TriggerConfig::density` -- the plotter's `Plot::set_density()`, an intensity grid
|
||||||
|
**per trace, drawn in that trace's own colour**, every acquisition deposited into it, which fades
|
||||||
|
either by a time constant or by a per-acquisition factor. There is **no depth** on the density and
|
||||||
|
no per-acquisition memory: the image is `traces * columns * rows * 4` bytes, and both factors are
|
||||||
|
bounded -- the grid is the row's axes rect in device pixels **up to the plotter's
|
||||||
|
`kDensityCellCap`**, and there is one grid a trace the row draws. Four traces in a row
|
||||||
|
therefore hold at most 4 MiB between them, whoever dragged the window where (the probe's
|
||||||
|
one-signal-per-row run reads `cells=1044714`, 4 178 856 bytes), so a thousand acquisitions cost
|
||||||
|
what one costs and *neither* a wide window nor a long run can make the image grow. A cell's value
|
||||||
|
is a number of *sweeps*: the library
|
||||||
|
deposits a cell once per trace per sweep however many of that trace's own samples crossed it
|
||||||
|
(`[density-probe] on` reads `peak=1.0000` for one sweep; a per-hit deposit read 18) -- dwell is
|
||||||
|
`Style::intensity_grading`'s question, not this one's. The colour is the trace's own
|
||||||
|
(`RowSet::signal_color()`, the same source the trace was built with and never a second one), so a
|
||||||
|
saturated cell is that signal's colour at full strength and two signals agreeing in a cell are two
|
||||||
|
quads of two colours rather than a blend.
|
||||||
|
Five things hold it together, each of which is a rule:
|
||||||
|
* **It is a trigger-mode display.** `apply_density()` (the tick) states `Held` and `Capturing` as
|
||||||
|
the states it is on in; `Live`, `Waiting`, `Paused` and a file are not, and turning it off drops
|
||||||
|
the grids **at once** rather than at an acquisition a quiet trigger never sends (the rule
|
||||||
|
`set_persist_depth()` follows). So it goes away with the trigger, and a re-enable starts from an
|
||||||
|
empty image rather than a stale one at another scale.
|
||||||
|
* **One deposit per acquisition, one frame *after* the frame that frames it.** `Plot::accumulate_density()`
|
||||||
|
deposits what the plot currently holds through the transforms of its **last** layout, because the
|
||||||
|
frame's own layout is computed inside the paint that follows the tick: `density_pending` is set
|
||||||
|
where a snapshot is *framed* -- beside `record_pending` -- and consumed by the next tick, so the
|
||||||
|
deposit is of the new acquisition drawn at the axis that acquisition is pinned to. Doing it in the
|
||||||
|
same tick was right on the common Held-to-Held transition and wrong on the first latch, whose axis
|
||||||
|
was still the live ring's.
|
||||||
|
* **The switch deposits what is already on screen.** `on && !density_was_on` sets the same pending
|
||||||
|
flag, so a user who turns persistence on while an event is held sees it instead of waiting for a
|
||||||
|
latch that `single` mode may never send.
|
||||||
|
* **No trace is drawn over it, and the newest acquisition is inside it.** `Renderer::draw_traces()`
|
||||||
|
returns at once while a density is on: the trace ink *is* the image, so the acquisition just
|
||||||
|
deposited is a sweep in it and fades with the rest. Drawing the polyline too put a solid,
|
||||||
|
unfading stroke over its own faint ghost -- the reported "persistent image + last acquisition",
|
||||||
|
where the last acquisition never faded in time mode -- and the assertion is a **framebuffer** one:
|
||||||
|
`a_phosphor_density_is_the_trace_in_its_own_colour` reads the same pixel before and after one
|
||||||
|
time constant and requires it dimmer (`full=224 -> faded=115`, the trace's own `0xE0` green),
|
||||||
|
which a stroke over the density could not do.
|
||||||
|
* **The brightness ceiling is the trace's own brightness, and the ramp is linear in sweeps.** The
|
||||||
|
app leaves `DensityConfig::full_scale` at the library's default of **one sweep** and `gamma` at
|
||||||
|
1.0, so the acquisition just deposited reads as the trace it is a picture of and a signal crossing
|
||||||
|
the same place every acquisition settles *at* the trace's colour (the ceiling clamps), rather than
|
||||||
|
a fraction of a brighter steady state. The old ceiling -- the fade's steady state, `1/(1-factor)`
|
||||||
|
for the acquisition fade -- is the gain offset a user reported between the latest acquisition and
|
||||||
|
the persistent image, and the "dimmer by design" headroom that made the ramp visible is reversed
|
||||||
|
here.
|
||||||
|
* **It is the plot's, so a figure rebuild resets it** -- the grids live on each `Plot`, and
|
||||||
|
`rebuild_all()` builds new ones. That is the one state a structural change (a row added, a signal
|
||||||
|
switched off, a trace recoloured) does not carry: it costs the accumulated image and nothing else,
|
||||||
|
and the alternative -- keeping the grids in the `RowSet` -- is what would make it not a plot
|
||||||
|
feature. The layout is unaffected either way: the density reserves no space and nothing on it can
|
||||||
|
be picked, exactly as `set_invalid_spans()` does.
|
||||||
|
* **The session carries it.** `trigger_density`, `trigger_density_decay`, `trigger_density_tau` and
|
||||||
|
`trigger_density_factor` in the layout file, written whether or not it is on -- a display set up
|
||||||
|
once and switched off is still the display you come back to. The decay is the *word* the panel's
|
||||||
|
dropdown shows ("acquisitions"/"time"), for the reason the condition's shapes are text.
|
||||||
|
|
||||||
|
## A latch is also a file
|
||||||
|
|
||||||
|
The cache is listed twice, from one `EventStore::list()`: the dock's compact **Events block** and the full list on the
|
||||||
|
`events` slide-over page (the left pane, its divider and the header toggle are gone, so events no longer take
|
||||||
|
any of the plot's width). What makes it *available* -- recording is on, **or** a replay is on screen, **or** the
|
||||||
|
cache already holds something (`event_recording || replaying || events_cached`) -- is decided in one place,
|
||||||
|
`sync_events_availability()`, and it sets the block's sensitivity (`dock_events_set_sensitive()`) and the page's
|
||||||
|
(`events_box`) from the one `available`, so they cannot disagree. Unavailable means **insensitive, never hidden**
|
||||||
|
("disable, do not hide"): the block stays on screen greyed, and turning recording off with the page open leaves it up
|
||||||
|
and greyed rather than stale and live.
|
||||||
|
|
||||||
|
**This deliberately differs from the redesign spec**, which has the block insensitive "outside trigger mode". Trigger
|
||||||
|
mode is *not* one of the conditions: a stored event being examined is a file, which has no trigger, and a replay or a
|
||||||
|
group *leaves* trigger mode, so a block that went grey outside trigger mode would grey itself under the very click
|
||||||
|
that started the replay -- the one-click replay in the dock would disable the dock it was clicked in. Do not "fix" this
|
||||||
|
to match the spec. The recording switch lives in the trigger panel, not in the block it empties.
|
||||||
|
|
||||||
|
The two views follow the latch on different cadences: the dock is rebuilt on **every** latch (`record_event()`), the
|
||||||
|
page's list only while the page is open, and `open_events_page()` refreshes it when it was closed; a caller that
|
||||||
|
opens the page any other way shows a list that is as stale as the last time it was open.
|
||||||
|
|
||||||
|
## Opening a file is a detour, so *Go live* is a restore
|
||||||
|
|
||||||
|
A file view overwrites the live view's settings and every overwrite has a reason: the window becomes
|
||||||
|
the event's own span because that is what *framing* an event means, and the trigger goes off because
|
||||||
|
a file has no edge to wait for (leaving it on kept a frozen capture on screen *instead* of the file,
|
||||||
|
so the click looked like it did nothing at all). None of those reasons survive the way back. What
|
||||||
|
the user had set is theirs, and "going back to live loses the trigger I spent a minute setting up"
|
||||||
|
is the complaint this answers.
|
||||||
|
|
||||||
|
- **`snapshot_live_state()` on the way in, `restore_live_state()` on the way out.** The snapshot is
|
||||||
|
taken by both ways into a file view -- `replay_event()` and `build_event_group()` -- *before* the
|
||||||
|
acquisition is stopped, and is a **no-op while `replaying`**: a second event opened from the first,
|
||||||
|
or a group opened from a replay, would otherwise snapshot a file's settings as "live", which are
|
||||||
|
the very values the restore exists to undo. The group takes it after its argument checks, because a
|
||||||
|
group that is refused leaves the live view alone.
|
||||||
|
- **The trigger channel is stored by *name*.** Same reason `TriggerEngine::configure()` takes one: the
|
||||||
|
index is a position in a channel list that a file view rebuilds, and a group **renames** its
|
||||||
|
channels outright (`square (A)`). Putting an index back would select a different signal.
|
||||||
|
- **`enabled` and `armed` are deliberately not restored from the snapshot.** They belong to the
|
||||||
|
toolbar toggle, which owns an atomic mirror of `enabled` (`set_trigger_enabled()`) that a plain
|
||||||
|
field write under the lock would leave stale. The restore writes the rest of the `TriggerConfig`
|
||||||
|
and then moves the *toggle*, whose handler states both.
|
||||||
|
- **The restore runs from the tick, not from `go_live_again()`, and *after* the deferred layout
|
||||||
|
block.** Two writers would otherwise beat it:
|
||||||
|
- `apply_layout()` ends in `apply_time_window(st.window_s)` -- the window the **session file**
|
||||||
|
holds, not the one the live view had. `go_live_again()` clears `layout_loaded`, so that block
|
||||||
|
fires on the next tick; a restore done inside `go_live_again()` is overwritten by the saved value
|
||||||
|
a frame later. Being the next statement after it in `tick()` is the fix.
|
||||||
|
- `rebuild_trigger_signals()`, finding a `channel` of `-1` after the rebuild renumbered the
|
||||||
|
channels, silently settles on the first signal *and* re-defaults the level -- which is why
|
||||||
|
`trigger_level_touched` is restored with the configuration.
|
||||||
|
- **It waits for the trigger's channel *name* to resolve again, not for `generation()` to move.** The
|
||||||
|
restored name has to be resolved against the live channels, and a file view that renamed its channels
|
||||||
|
(`square (A)`) must be gone first or the name selects the wrong signal. `App::live_restore_pending`
|
||||||
|
stays set until `channels()` is non-empty **and** a channel carries the snapshot's name (or the
|
||||||
|
snapshot named none). Gating that on the generation was wrong in the common case: a *single* event
|
||||||
|
keeps the stream's own channel names, and the restarted client's CONFIG is identical, which the model
|
||||||
|
treats as **no change on purpose** (`same_stream_signals()`) -- so the generation never moved and the
|
||||||
|
restore waited for something that could not happen. What shook it loose was toggling the trigger,
|
||||||
|
which *is* a structural change: "click live, then toggle the trigger a couple of times" was the
|
||||||
|
symptom. A `pending=1` that never clears is a reading about the *stream* (no channels yet), not about
|
||||||
|
the restore -- see `invariants-session-and-export.md` for the save that made it stick at
|
||||||
|
`channels=0`.
|
||||||
|
- **Only the two controls a file view actually moved are restated** -- the signal dropdown and the
|
||||||
|
level spin. The rest of the trigger panel was never touched, and writing to a control fires its
|
||||||
|
handler, so the ones with nothing to put back are left alone. Both writes are inside
|
||||||
|
`updating_panels`.
|
||||||
|
- **`pinned` is not a pass/fail reading on the way back.** `apply_time_window()` drops the file's pin
|
||||||
|
and re-arms the live follow, but if the restored trigger was *on* it can latch again within the
|
||||||
|
frame or two before the probe reads -- and a held capture is pinned, correctly. That is what
|
||||||
|
`held=` and `mode=` are beside it for.
|
||||||
|
|
||||||
|
## A comparison is two plots of one model
|
||||||
|
|
||||||
|
*Compare* and *accumulate* are still one model -- the buffers cannot hold two independent loads -- but a
|
||||||
|
comparison is **two plots** over it, and which plot draws what is only a matter of which channel *names*
|
||||||
|
its rows bind. `App::rows` is the primary (`RowSet`, area, gestures); `App::rows_b` is the second, with
|
||||||
|
its own `RowSet` and its own hidden `PlotArea` in a paned, deliberately without the drop target, the
|
||||||
|
right-click menu or the frame counter: those act on the primary's rows, and a menu on the second plot
|
||||||
|
that rearranged the first plot's rows would be a menu lying about its subject.
|
||||||
|
|
||||||
|
Rules that cost a defect each:
|
||||||
|
|
||||||
|
- **The layout is the user's and is mirrored, never reinvented.** The group build must not call
|
||||||
|
`set_row_count()`; it reads the rows' slot names and re-names the same slots for each layer
|
||||||
|
(`… (A)`, `… (B)` for a comparison; a bare name for layer 0 and `… #2`, `… #3` for the extra layers of
|
||||||
|
an accumulation). An *accumulation* puts its extra layers into free slots -- the row that holds the
|
||||||
|
signal first, then any row with room -- so the user's traces never move. The default layout is four
|
||||||
|
signals in one row, which is why `kSlotsPerRow` is 8: with four, "accumulate these two events" had
|
||||||
|
nowhere to put the second copy and silently did nothing.
|
||||||
|
- **A layer is not a signal.** A comparison's model holds one channel per (signal, layer), and the
|
||||||
|
*panel* must not treat the copies as signals of their own: the signal list shows one row per signal
|
||||||
|
(`layer_base_name()`, the shared rule that takes `(A)`, `(B)` and `#N` back off), `signal_color_index()`
|
||||||
|
counts *signals* and not channels -- counting channels spread one signal's colour across the palette
|
||||||
|
-- and both plots of a comparison bind their signal's layers to the *same* colour index, because two
|
||||||
|
colours would say they were different signals. The reading is `list=4` in `[events] compare plots`
|
||||||
|
for a two-event comparison of four signals.
|
||||||
|
- **A slot name carries the last group's suffix, so it must be stripped before it is re-derived**:
|
||||||
|
after a comparison the rows are named `sine (A)`, and a following accumulation that mapped that name
|
||||||
|
again bound to no channel at all (`traces0=0`, a plot with nothing in it). `base_name()` takes
|
||||||
|
`(A)`, `(B)` and `#N` back off.
|
||||||
|
- **The axes are mirrored from the ranges, once per frame.** The plotter owns its window and a gesture
|
||||||
|
reaches it through the plot's own setter with nothing announcing it, so the frame compares each row's
|
||||||
|
signature with the previous frame's: whichever side moved is the side the user touched, and the other
|
||||||
|
is set to match. The signature is `{span, y.min, y.max}` when the x axis has a **live edge**
|
||||||
|
(`x_live_edge()`, not merely an armed follow: an armed axis with nothing streaming keeps a *stored*
|
||||||
|
window, whose edges a pan moves and whose span never changes) and `{x.min, x.max, y.min, y.max}`
|
||||||
|
otherwise. Comparing the edges under a live edge would read as "both sides moved" on every frame --
|
||||||
|
the window is recomputed as a distance behind the newest sample -- and a zoom in one plot would be
|
||||||
|
overwritten by the other's drift. When both moved the first plot is the reference, which is also what
|
||||||
|
makes the frame after a build converge.
|
||||||
|
- **`set_y_range()` clears that axis' auto-scale**, by design: a mirrored zoom is a user decision, and
|
||||||
|
*Fit* re-arms it.
|
||||||
|
|
||||||
|
## A group of events is one model, built once
|
||||||
|
|
||||||
|
*Compare* and *accumulate* are the same machinery: several event files in **one** model, because the
|
||||||
|
buffers cannot hold two independent loads -- `load_waveform_into_model` calls `set_signals()`, which
|
||||||
|
rebuilds them, so a second load takes the first with it. So the group is built by hand: one channel per
|
||||||
|
(signal, layer) pair, `set_target_span()` to cover the whole group *before* `set_signals()` (or the
|
||||||
|
oldest samples are written past the ring's end), then all of the events pushed in one pass. A row is
|
||||||
|
one **signal** and its slots are the events, which is what makes a comparison like for like and an
|
||||||
|
accumulation a density; the trace alpha (0.35 while accumulating, 1 otherwise) is what turns agreement
|
||||||
|
into brightness.
|
||||||
|
|
||||||
|
Two things every group must do, both of which cost a defect to learn:
|
||||||
|
|
||||||
|
- **Stop the acquisition first.** A live producer keeps pushing into the model it was started with --
|
||||||
|
and it pushes *four* stream signals into the group's *eight* channels, so the group's own samples are
|
||||||
|
the ones that go missing. The reading that catches it is the samples the busy channel holds
|
||||||
|
(`samples0` in the `[events] compare` line): it must be exactly the number of stamps pushed, not
|
||||||
|
that plus whatever the stream added.
|
||||||
|
- **Free the slots the group does not fill.** A row carries the previous view's binding, and a trace
|
||||||
|
left in slot 2 of a two-layer row is drawn as if it belonged to the group.
|
||||||
|
|
||||||
|
*Compare* shifts the first event so its span *ends* where the second's *begins*, and fills the half a
|
||||||
|
sample does not belong to with **NaN** -- the plotter excludes non-finite samples from every series and
|
||||||
|
from every pyramid bucket, so a NaN is a gap and not a repeat, and no line is drawn across the join.
|
||||||
|
|
||||||
|
**A replay changes the data and frames it once; it never rearranges the layout.** A row plots a signal
|
||||||
|
*name*, and an event's export carries the same names the stream does, so loading the event into the
|
||||||
|
model leaves every row, slot, colour and the row count pointing at the right signal by itself. So
|
||||||
|
`replay_event()` must **not** rebind the rows (`bind_channels_to_rows()`, which is what a freshly opened
|
||||||
|
*file* needs) -- that is the user's work, and a click on a list is not a request to rearrange it. The
|
||||||
|
window is restated **once, on open** (`apply_time_window(span)`, the only path allowed to change the
|
||||||
|
scale), so the whole event is framed the moment it is opened and the user's own zooms are left alone
|
||||||
|
afterwards; never per frame. The measurements, the cursors and the readouts then report the stored
|
||||||
|
event, because they read the model. What a replay *does* change is the model's contents and the trigger:
|
||||||
|
it leaves trigger mode, without which the display stays `Held` and draws the old snapshot.
|
||||||
|
|
||||||
|
**A file view has nothing to acquire.** The signal list's acquire tick is *hidden* -- not disabled --
|
||||||
|
whenever a file is on screen (`App::waveform_name` non-empty: a replay, a comparison, a `--open`run).
|
||||||
|
The samples are stored, "acquire this signal" is a stream's question, and a greyed tick invites the user
|
||||||
|
to look for a reason there is none for. Going live rebuilds the panel, so the ticks come back.
|
||||||
|
|
||||||
|
**"The same length" is the same count at the same rate, not the same span to the sample.** Two events
|
||||||
|
this app recorded itself differ in span by the fraction of a sample the acquisition's window happened to
|
||||||
|
fill to (0.9999 s of a 1 s pre-window is normal). Demanding one sample interval made a comparison of two
|
||||||
|
just-recorded events refuse to open; the check allows four intervals or a millisecond, whichever is
|
||||||
|
larger, and the merged stamp grid carries the difference as a gap.
|
||||||
|
|
||||||
|
**A replay must leave trigger mode.** `display_mode()` answers `Held` whenever the model holds a
|
||||||
|
snapshot, and a held display draws *the snapshot*, not the live channels -- so replaying an event with
|
||||||
|
trigger mode still armed loaded the file into the model and left the old capture on screen: a click
|
||||||
|
that looks like it did nothing, with the event sitting in the ring unread. The replay also frames the
|
||||||
|
view with `apply_time_window(report.span_s)`, so the click shows the whole event rather than the part
|
||||||
|
of it that fell inside the live window.
|
||||||
|
|
||||||
|
The switch that decides recording belongs to the **trigger panel**, not to the pane it empties: it
|
||||||
|
is a trigger option (it decides whether a latch is written at all), and a pane must not be the only
|
||||||
|
place to reach the control that stops it.
|
||||||
|
|
||||||
|
|
||||||
|
Every published snapshot is written to the event cache while recording is on: its table (the *export*
|
||||||
|
format, so `--open` reads it back), a sidecar of what the list shows, and a preview drawn from the
|
||||||
|
samples. Three rules keep that honest:
|
||||||
|
|
||||||
|
- **The record happens on the GTK thread, at the end of the tick that framed the snapshot**, not on
|
||||||
|
the producer. The snapshot is taken by `shared_ptr` (a latch landing mid-write cannot pull the data
|
||||||
|
away), and a `record_pending` flag defers the write to the end of the frame so the rows are already
|
||||||
|
bound to the event when the picture is drawn. Recording from the producer would put file I/O
|
||||||
|
between two `recv()` calls, which is the one thing the packet capture's design also refuses.
|
||||||
|
- **Nothing a latch does may be expensive.** The write is a `write_npy` of the event's own window and
|
||||||
|
a 480x160 raster; a 2 s, four-channel event is ~230 kB of picture and ~600 kB of samples. The pane
|
||||||
|
is refreshed, not rebuilt, and a failure to write is a line on stderr -- a cache that cannot be
|
||||||
|
written must not stop the scope.
|
||||||
|
- **The preview is drawn from the data, never captured from the window.** The plot is a `GtkGLArea`:
|
||||||
|
its ink is not in the widget's paint tree, so `gtk_snapshot_free_to_node()` on a widget paintable of
|
||||||
|
it returns NULL and the "screenshot" would be an empty file. Drawing it in the store also keeps that
|
||||||
|
class GTK-free, which is what makes the cache testable without a window.
|
||||||
|
|
||||||
|
- **A pin is mirrored as a *range*, never as a pair of numbers that might be spans.** `Range`
|
||||||
|
(`pinned_range()`: `{-pre, +post}`) and the pin's *spans* (`pin_event_window(pre, post)`) are two
|
||||||
|
different things that both look like a pair, and mixing them inverts the window: a second view
|
||||||
|
that handed `pinned_range().min` to `pin_event_window()` pinned to `[+pre, +post]`, and the plot
|
||||||
|
drew nothing — no trace, no axis labels — because the x range was degenerate. Views that mirror a
|
||||||
|
pin use `pin_display_range(x0, x1)`, the range's own form. `[scope-probe] pin_mirror=1` reads the
|
||||||
|
two windows' pins and the scope's x range, and an inverted pin reads `0`.
|
||||||
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 130 KiB |
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 129 KiB |
@@ -0,0 +1,217 @@
|
|||||||
|
# Project review — 2026-10-13
|
||||||
|
|
||||||
|
A status review for performance, defects and gaps. Every claim below was read back against the
|
||||||
|
current tree; where a candidate finding turned out to be wrong it is listed as dismissed, with the
|
||||||
|
reason, rather than dropped — a false alarm that is not written down gets re-raised.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The project is in good shape. All ten `todo.md` items are implemented, every deviation from
|
||||||
|
`SPECS.md` is documented in README's *Review findings not (yet) implemented* and *Limitations*, there
|
||||||
|
is no `TODO`/`FIXME`/`HACK` anywhere in `src/`, no dead command-line option, and no settings field
|
||||||
|
that is written but never read. The tree builds clean.
|
||||||
|
|
||||||
|
- **Defects: none confirmed.** Four candidates were investigated and all four are correct as
|
||||||
|
written (§2).
|
||||||
|
- **Performance: one real win on the ingest hot path** (§1.1) — a 64-bit `div` per sample per
|
||||||
|
channel that the code's own comment believes is already cheap. Then one real win on the idle
|
||||||
|
frame path (§1.2), and three small ones. **§1.1 and §1.2 have since been implemented**; the three
|
||||||
|
small ones (§1.3-§1.5) have not.
|
||||||
|
- **Gaps: no missing requirement.** What is missing is only what was never asked for (§4), plus one
|
||||||
|
stale sentence in README (§3).
|
||||||
|
|
||||||
|
## 1. Performance
|
||||||
|
|
||||||
|
### 1.1 A 64-bit division per sample per channel on the producer hot path
|
||||||
|
|
||||||
|
> **Implemented** (2026-10-13), as the fix below describes: `Acquisition::acc_mask_` holds the
|
||||||
|
> per-channel masks, built where the accumulators are sized; `reserve_hole()` moved with it; the
|
||||||
|
> misleading comment at `src/model.cpp:120-122` now says what the mask actually buys. Written up in
|
||||||
|
> README under *What the ingest filter and a still frame cost*.
|
||||||
|
|
||||||
|
`src/acquisition.cpp:856-857`, in the innermost loop of `on_data()`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const u64 period = g * part.stride[i];
|
||||||
|
if (tick % period != period - 1) continue;
|
||||||
|
```
|
||||||
|
|
||||||
|
Three facts make this the most expensive avoidable thing in the application:
|
||||||
|
|
||||||
|
1. **`part.stride[i]` is always 1.** `plan_partition()` assigns it (`src/model.cpp:49`) and then
|
||||||
|
explicitly re-asserts it (`src/model.cpp:70`), with the comment above it explaining why
|
||||||
|
per-channel strides were abandoned. So `period == g` for every channel, always — the load and
|
||||||
|
the multiply are provably redundant.
|
||||||
|
2. **`tick % period` is a real `div`.** `g` is a runtime `u64`, so the compiler cannot strength-
|
||||||
|
reduce it, whatever its value. A 64-bit integer division is ~20-40 cycles of latency on x86.
|
||||||
|
The intent was otherwise: `src/model.cpp:120-122` says a power-of-two stride "keeps `tick %
|
||||||
|
period` cheap", which would be true of a compile-time constant and is not true of a variable.
|
||||||
|
**`time_stride` being a power of two currently buys nothing here.**
|
||||||
|
3. **The same condition is already computed.** `tick % g == g - 1` is evaluated at
|
||||||
|
`src/acquisition.cpp:811` for the time ring, and again at `:881` for the math push — and
|
||||||
|
`:857` is the same test once more per channel.
|
||||||
|
|
||||||
|
Cost model: `(n_channels + 2)` divisions per **raw** sample, i.e. per wire sample, not per stored
|
||||||
|
one — the decimation filter deliberately reads every sample (`:850-852`). At the fast mock's
|
||||||
|
1.3 MS/s with 4 channels that is ~7.8 M divisions a second on the producer thread, which is the
|
||||||
|
thread whose budget overrun costs packets rather than frames.
|
||||||
|
|
||||||
|
Fix: compute the group boundary **once per sample** and reuse it. `g` is a power of two, so
|
||||||
|
`const u64 mask = g - 1;` hoisted out of the packet loop turns all three sites into
|
||||||
|
`(tick & mask) == mask`. To keep the per-channel generality the type still carries, precompute a
|
||||||
|
per-channel mask vector when the partition is planned rather than per sample.
|
||||||
|
|
||||||
|
The same expression appears in the hole path at `src/acquisition.cpp:966` (inside
|
||||||
|
`reserve_hole()`'s double loop). That one is cosmetic — it runs only on packet loss — but it should
|
||||||
|
move with its twin so the two paths keep matching.
|
||||||
|
|
||||||
|
How to verify: the fast mock (`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), reading the
|
||||||
|
exit dump's `gaps=` and the producer's CPU share before and after. Expect the win to show as
|
||||||
|
headroom rather than as a changed reading on a stream the producer already keeps up with.
|
||||||
|
|
||||||
|
### 1.2 The figure is re-rendered 60 times a second even when the picture cannot change
|
||||||
|
|
||||||
|
> **Implemented** (2026-10-13), conservatively: a signature of what a frozen view can be re-framed
|
||||||
|
> by, opening the gate for `kSettleRenders` frames, plus a `kStaticRenderEvery` floor that renders
|
||||||
|
> anyway every four ticks, and the layout editor excluded outright. `[pause-probe]`'s new `skips=`
|
||||||
|
> field reports it: a measured run read 0 live, 33 paused, with `columns == graded`, `render blank
|
||||||
|
> frames: 0` and the layout floor still converged.
|
||||||
|
|
||||||
|
`src/ui.cpp:3709` queues a GL render unconditionally at the end of every tick, and the tick is a
|
||||||
|
fixed 16 ms timeout (`src/ui_app.hpp:1023`). In `Held` and `Paused` the bound data is an immutable
|
||||||
|
snapshot and the axis is pinned, so with no pointer interaction the frame is provably identical to
|
||||||
|
the last one — and it is still fully re-rendered. On llvmpipe, or on a laptop looking at a latched
|
||||||
|
event, that is the whole GPU cost of the application for nothing.
|
||||||
|
|
||||||
|
Fix: queue the render only when something that can change the picture did — new samples acquired,
|
||||||
|
a mode/epoch/pin change, a mark/cursor/label move, an interaction, a resize. Keep it conservative:
|
||||||
|
render unconditionally while the layout is still settling, since the layout editor's buttons are
|
||||||
|
placed from the *previous* frame's rects and several probes read a value one frame after the ask.
|
||||||
|
|
||||||
|
How to verify: the exit dump's per-plot painted-frame counts and `layout over N frames` are exactly
|
||||||
|
the two readings that would catch an over-eager skip.
|
||||||
|
|
||||||
|
### 1.3 The event publish blocks the UI tick for 69-115 ms
|
||||||
|
|
||||||
|
Already documented, with measurements, at `README.md:2189-2191` — listed here only because the
|
||||||
|
README prices exactly one remedy (a second live ring) and declines it for good reasons. Two
|
||||||
|
options it does not price, both cheaper than a second budget's worth of memory:
|
||||||
|
|
||||||
|
- do the snapshot copy on the **producer** side of the handoff, so the stall lands where a stall is
|
||||||
|
already tolerated at a latch (the pause publish already blocks the producer, 22-86 ms);
|
||||||
|
- chunk the copy across frames behind a sequence number, publishing only when complete.
|
||||||
|
|
||||||
|
Worth a design note before any code — the README's own A/B (`gaps=0` either way) says this buys a
|
||||||
|
smoother frame at a latch, not recovered packets, so it is a polish item, not a correctness one.
|
||||||
|
|
||||||
|
### 1.4 The status detail string is rebuilt and re-applied every frame
|
||||||
|
|
||||||
|
`src/ui.cpp:4063` calls `format_detail(snap)` every tick and `:4082` pushes the result into
|
||||||
|
`gtk_widget_set_tooltip_text()` every tick. The popover is already guarded by its own visibility
|
||||||
|
(`:4083-4087`); the tooltip is not, and the string is long enough that the code tracks its maximum
|
||||||
|
length. Nothing reads it unless the pointer rests on the status label.
|
||||||
|
|
||||||
|
Fix: keep the last applied detail string and skip both the format and the setter when it is
|
||||||
|
unchanged. Low risk, no behaviour change.
|
||||||
|
|
||||||
|
### 1.5 Search marks are re-filtered and re-sorted every frame
|
||||||
|
|
||||||
|
`src/ui.cpp:1760-1768`. Two heap vectors, a pass over every hit and an `std::sort` per frame for as
|
||||||
|
long as a search result set exists. Correctly short-circuited when there are no hits
|
||||||
|
(`:1753`), and being recomputed every frame is load-bearing for a *live* view (the marks are stored
|
||||||
|
in stream seconds and the window rolls under them) — but in `Held`/`Paused` neither the hits nor
|
||||||
|
the view range move, so the whole result can be cached and invalidated on a view-range or hit-set
|
||||||
|
change.
|
||||||
|
|
||||||
|
### 1.6 Noted, not recommended: the model mutex spans all of `on_data()`
|
||||||
|
|
||||||
|
`src/acquisition.cpp:558` takes the lock and holds it to the end of the function (~line 913),
|
||||||
|
covering the sample loop, the math evaluation, the trigger engine, and hole reserve/backfill. The
|
||||||
|
UI tick takes the same mutex (`src/ui.cpp:3285`). This is the documented design ("taken once per
|
||||||
|
DATA frame", `docs/claude/architecture.md:15-16`) and it is the right call at today's rates, but it
|
||||||
|
is the coupling by which math-channel cost on the producer turns into UI frame jitter. The existing
|
||||||
|
`math_timed` per-slot instrumentation is the thing to watch if math graphs get heavier.
|
||||||
|
|
||||||
|
## 2. Defect candidates investigated and dismissed
|
||||||
|
|
||||||
|
Recorded so they are not re-raised.
|
||||||
|
|
||||||
|
- **`capture_edge()` loading `memory_order_relaxed`** (`src/model.hpp:492`) while its partner
|
||||||
|
`trigger_pending()` carries release/acquire. **Correct as written.** The consumer reaches
|
||||||
|
`capture_edge()` at `src/ui.cpp:3275` only *after* `display_mode()` at `:3273` has done the
|
||||||
|
acquire load of `trigger_pending_`; the acquire establishes the happens-before, so a relaxed load
|
||||||
|
afterwards cannot see the stale edge, and an acquire also forbids the later load being reordered
|
||||||
|
ahead of it. The probe path (`src/ui_probe.cpp:1129-1130`) reads the flag first too.
|
||||||
|
*One fragility worth a line of doc:* the correctness is a property of **read order at the call
|
||||||
|
site**, and `src/model.hpp:489-491` says a consumer that sees the flag sees the edge without
|
||||||
|
saying that the consumer must therefore read the flag first. Tightening that comment is the whole
|
||||||
|
of the work.
|
||||||
|
- **`math_values_.data()` passed to `math_step()` across a possible reallocation**
|
||||||
|
(`src/model.cpp:409-422`). **Safe.** `push_math_sample()` is only reached from `on_data()`
|
||||||
|
(`src/acquisition.cpp:881`, `:978`), which holds the model mutex for its whole body, and
|
||||||
|
`set_math_channels()` takes the same mutex.
|
||||||
|
- **`model_.signals()` indexed by `channels[0].signal` after the lock was released**
|
||||||
|
(`src/acquisition.cpp:593-595`). **Safe** — same reason: the lock is held from `:558` to the end
|
||||||
|
of the function, so no CONFIG can re-shape the signal list underneath it.
|
||||||
|
- **`events_thumb` left dangling across a list rebuild** (`src/ui_events.cpp:632`). **Already
|
||||||
|
handled** — nulled before the rows are removed and rebound after, with the old texture unreffed.
|
||||||
|
|
||||||
|
Nothing in the trigger/pause/file-view/replay seams, the math-channel dependency handling, the
|
||||||
|
measurement and cursor paths, or the export/session writers reproduced a wrong number or a lost
|
||||||
|
action on inspection. That is a statement about a code read, not about a test run: README's own
|
||||||
|
`Limitations` is right that these paths are checked headless, and the seams between two *user*
|
||||||
|
actions are where the probes are thinnest.
|
||||||
|
|
||||||
|
## 3. One documentation defect
|
||||||
|
|
||||||
|
`README.md:2304-2307` says the interactive paths — "drag and drop, the dialogs, the right-click
|
||||||
|
menu" — "were not driven by an automated test". That is now stale in all three particulars:
|
||||||
|
`UDPSCOPE_PROBE_DROP_MS` drives the drop handler, `UDPSCOPE_PROBE_ROWMENU_MS` walks the
|
||||||
|
right-click menu and clicks through its submenu, and `UDPSCOPE_PROBE_DEFAULTS_MS`,
|
||||||
|
`UDPSCOPE_PROBE_CONFIGS_MS`, `UDPSCOPE_PROBE_PREFS_MS` and `UDPSCOPE_PROBE_HELP_MS` all open and
|
||||||
|
read back dialogs. What remains genuinely undriven is the *gesture* layer (a real pointer drag, a
|
||||||
|
real popover dismissal) rather than the handlers behind it, which is a much narrower claim and the
|
||||||
|
one worth making.
|
||||||
|
|
||||||
|
## 4. Gaps
|
||||||
|
|
||||||
|
### Requirements: complete
|
||||||
|
|
||||||
|
`SPECS.md` was walked bullet by bullet. Everything is implemented, and the five places where the
|
||||||
|
implementation differs are each documented with a reason at `README.md:2182-2240`: one live buffer
|
||||||
|
plus an immutable `EventSnapshot` rather than two live rings; allocation at the first CONFIG rather
|
||||||
|
than at startup; no per-channel decimation (the spec's formula aliased a 2 kHz sine on a 1 MHz clock
|
||||||
|
to a flat line); pre-trigger % as a share of the event window; and a capture drawn as it fills.
|
||||||
|
`todo.md`'s ten items are all really there, including item 9's full preference list — the
|
||||||
|
light/dark/auto theme, the reset-to-defaults and the cache controls are all in `src/settings.hpp`
|
||||||
|
and `src/ui_prefs.cpp`.
|
||||||
|
|
||||||
|
### Not asked for, worth considering
|
||||||
|
|
||||||
|
Ranked by value against cost, not by ambition:
|
||||||
|
|
||||||
|
1. **Save the current view as an image.** The cheapest of these by a wide margin: the event cache
|
||||||
|
already has a sample-driven image writer (`write_preview()`, `src/event_store.cpp:266`), so a
|
||||||
|
menu item and a file chooser is most of the work. A scope user screenshots constantly.
|
||||||
|
2. **A draggable trigger level on the plot.** The trigger marker exists but is a vertical cursor
|
||||||
|
and deliberately non-selectable (`src/plot_host.cpp:250`); the level is reachable only through
|
||||||
|
the panel's spin button. A horizontal handle on the trigger channel's row is the standard
|
||||||
|
gesture on every bench scope.
|
||||||
|
3. **Per-trace vertical offset and scale.** Up to eight signals share one row's y axis, so two
|
||||||
|
signals of different magnitudes cannot both be read. This is the gap a user hits soonest.
|
||||||
|
4. **An FFT / spectrum row.** The largest of these, and the one a UDPStreamer user is most likely
|
||||||
|
to ask for next; it fits the figure's row model (one row, one derived series) rather than
|
||||||
|
fighting it.
|
||||||
|
5. **XY mode**, and **undo for layout edits** — both real scope features, both a poor fit for the
|
||||||
|
current one-x-window figure and the layout file respectively. Mentioned for completeness.
|
||||||
|
|
||||||
|
## 5. Suggested order
|
||||||
|
|
||||||
|
1. §1.1, the ingest-path division — small, local, the clearest win, and it corrects a comment that
|
||||||
|
is currently misleading about why the power of two matters.
|
||||||
|
2. §3, the stale README sentence — minutes, and it is wrong today.
|
||||||
|
3. §1.4 and §1.5, the per-frame string and sort — small and safe.
|
||||||
|
4. §2's doc tightening on `capture_edge()`.
|
||||||
|
5. §1.2, the conditional render — the bigger win of the two performance items, but the one that
|
||||||
|
needs care against the layout-settling probes, so it deserves its own change.
|
||||||
|
6. §4.1, the screenshot — the cheapest real feature on the list.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# UI files and current GTK practice
|
||||||
|
|
||||||
|
Where the application's interface should be a `.ui` resource instead of widget construction, what is
|
||||||
|
already there, and the order to finish it in. Written 2026-10-11.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Three reasons, in the order they bite.
|
||||||
|
|
||||||
|
1. **Some things can only be said in a UI file.** The shortcut overview is the proof: GTK's
|
||||||
|
`GtkShortcutsShortcut` has exactly one gesture type that draws an `icon`, and libadwaita's
|
||||||
|
`AdwShortcutsItem` has no icon property at all -- so a mouse gesture can only be shown as a glyph
|
||||||
|
from a `.ui`. `src/ui/shortcuts.ui` is that, and it is the reason the file exists.
|
||||||
|
2. **Structure is data.** A window's arrangement rewritten in C++ is a hundred lines of
|
||||||
|
`gtk_box_append` that no one can read at a glance, cannot be compared with a diff, and cannot be
|
||||||
|
previewed. A UI file is the same arrangement in the notation the GNOME ecosystem reads.
|
||||||
|
3. **Current GTK and libadwaita have better widgets than this app uses.** The forms here are
|
||||||
|
`GtkEntry`/`GtkSpinButton` in hand-built label rows. The modern equivalents -- `AdwEntryRow`,
|
||||||
|
`AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow`, inside `AdwPreferencesPage`/`AdwPreferencesGroup` in an
|
||||||
|
`AdwPreferencesDialog` -- are what the preferences dialog should be, and they come from the same
|
||||||
|
move.
|
||||||
|
|
||||||
|
## Conventions (established, and in `docs/claude/invariants-gtk-and-chrome.md`)
|
||||||
|
|
||||||
|
- `.ui` files live in `src/ui/` and are compiled by the third GResource (`udpscope-ui.gresource.xml`,
|
||||||
|
`prefix=/org/udpscope/ui`), loaded with `gtk_builder_new_from_resource()`.
|
||||||
|
- Every file carries `preprocess="xml-stripblanks"`, which is the only thing that catches a malformed
|
||||||
|
file at build time.
|
||||||
|
- A file that is valid XML but wrong for GTK **aborts the process** (`Gtk-ERROR`). The probe that opens
|
||||||
|
every window is what catches that; there is no build-time substitute.
|
||||||
|
- A `GIcon` property takes a nested object, not a name.
|
||||||
|
- A `.ui` over 4095 characters needs `-Wno-overlength-strings` on its generated source.
|
||||||
|
- A window built from a builder is replaced (not reused) on the next opening: it belongs to the
|
||||||
|
builder's object list, and a destroyed `GtkWindow` cannot be presented again.
|
||||||
|
- A deprecated *widget* is acceptable when no deprecated *function* is called.
|
||||||
|
|
||||||
|
## Acceptance for every slice
|
||||||
|
|
||||||
|
The same as any other change here: the build stays at **four warnings**, a run stays at **no
|
||||||
|
`Gtk-CRITICAL`**, and the slice's own surface is read back by a probe rather than asserted. A new `.ui`
|
||||||
|
needs a probe that proves it *loads* and that its contents match whatever drives it -- `[help]`'s
|
||||||
|
`ui=` against `tables=` is the model.
|
||||||
|
|
||||||
|
## Already done
|
||||||
|
|
||||||
|
- `src/ui/main-window.ui` -- the main window's **skeleton**: the window, its two panes, the plot column
|
||||||
|
with the status line under it, and the sidebar's scroller. The four subtrees that depend on the stream
|
||||||
|
(the events pane, the two plot areas, the sidebar's panels, the status popover) are still built by
|
||||||
|
code and put into the slots by id.
|
||||||
|
- `src/ui/source-form.ui` -- the form a source is added or edited with: an `AdwDialog` of
|
||||||
|
`AdwPreferencesGroup` rows, with its action in the header bar where the label says which mode it is in.
|
||||||
|
- `src/ui/help.ui` -- the help window's chrome: an `AdwWindow` with a header bar and an
|
||||||
|
`AdwNavigationSplitView` whose sidebar is the page list. The rows stay in C++ because the fourteen
|
||||||
|
pages are data, not template.
|
||||||
|
- `src/ui/preferences.ui` -- the preferences, as an `AdwPreferencesDialog` with `AdwEntryRow`,
|
||||||
|
`AdwSpinRow`, `AdwSwitchRow`, `AdwComboRow` and `AdwButtonRow` rows. The Apply button is gone: a row
|
||||||
|
applies as it changes, which is what those widgets are designed around, and `[prefs-probe]` reads the
|
||||||
|
file back after every change.
|
||||||
|
- `src/ui/shortcuts.ui` -- the shortcut overview, with the mouse rows drawn as an icon cell.
|
||||||
|
- The `src/ui/` GResource and its build block.
|
||||||
|
- `[help]`'s `ui=`/`ui_accel=`/`ui_icon=`/`ui_bare=` reading, which checks the file against the tables
|
||||||
|
and asserts that no row is left without a visual.
|
||||||
|
|
||||||
|
## Remaining, smallest first
|
||||||
|
|
||||||
|
| where | what | why it is the right next step |
|
||||||
|
|---|---|---|
|
||||||
|
| `src/source_dialog.cpp` (the chooser's own window) | the chooser: `.ui` for the window, its two pages and its action row. Its **host form is done**; what is left is the list, the notebook and the buttons around it | the last hand-built UI outside `ui.cpp`, and the file is already open |
|
||||||
|
| `build_measures_panel` (`src/ui.cpp`, ~82 lines) | the measurements panel: `.ui` + `tools/check_ui.py` after | small, self-contained, and its own probe |
|
||||||
|
| `src/ui/ui_trigger.cpp` (~22 kB) | the trigger panel: the `.ui` holds the panel and its groups; the signal chips and the condition rows stay in code because they follow CONFIG | the largest user-visible panel |
|
||||||
|
| `src/ui/ui_signals.cpp` (~20 kB) | the signals panel | mostly a list of data-driven rows, so the `.ui` holds the frame |
|
||||||
|
| `src/ui/ui_math.cpp` (~48 kB) | the math panel and the node editor's chrome (the canvas itself is drawn, not declared) | the largest file, and the one where least of it is template |
|
||||||
|
| `src/ui/ui_events.cpp` (~44 kB) | the events pane | the rows are events, the frame is not |
|
||||||
|
| `src/ui/ui_layout.cpp` (~20 kB) | the layout editor's overlay buttons, which are positioned by code | only the buttons are template |
|
||||||
|
| `build_toolbar` (`src/ui.cpp`, ~212 lines) | the header bar's layout, with every button still wired in code | delicate: the `[udpscope] header:` probe reads its geometry |
|
||||||
|
|
||||||
|
**Every row of the table above is done or accounted for.** The header bar went last and in one change, because
|
||||||
|
`gtk_header_bar_pack_start()` appends after whatever a file has already packed -- staging it by group would
|
||||||
|
have reordered the bar in an intermediate commit. Its frame is `src/ui/header-bar.ui`; the overflow menu's
|
||||||
|
rows, the window control and every handler stayed in code, and the probe's header readings are byte-identical
|
||||||
|
before and after.
|
||||||
|
|
||||||
|
**The layout editor's buttons are not a `.ui` row, and the note above is wrong about them.** Their count *is*
|
||||||
|
the row count (one per row, one per boundary), and `GtkBuilder` instantiates a file's objects once, so a file
|
||||||
|
can hold neither the number nor a prototype for the code to copy. What was real in that row was the
|
||||||
|
duplication: the shared look is now one function (`layout_overlay_button()`), and the layout probe reads
|
||||||
|
byte-identical before and after. See `invariants-gtk-and-chrome.md`.
|
||||||
|
|
||||||
|
**Done since, one row per commit:** the source chooser's window (`0a64e9e`), the measurements panel
|
||||||
|
(`d344361`, `07fcf61`), the trigger panel (`1ccc70c`), the signals panel (`c37fbdd`), the math panel
|
||||||
|
(`90a17eb`) and the math editor's window, identity row and canvas. What is left of that row is the palette
|
||||||
|
and the buttons around the canvas, which stay in code for the reason the canvas's *drawing* does.
|
||||||
|
|
||||||
|
**Done since:** the sidebar is an `AdwOverlaySplitView` (`src/ui/main-window.ui`) rather than a paned's end
|
||||||
|
child, so its width is its own 320 px and it overlays the plot instead of collapsing to zero — see
|
||||||
|
`[sidebar-probe]`.
|
||||||
|
|
||||||
|
Each of these is one change on the pattern `src/ui/ui_cursors.cpp` now follows: `ui_load()` the file, resolve
|
||||||
|
the ids, keep every value, signal and rebuild in code, and run the panel's own probe before committing.
|
||||||
|
|
||||||
|
Nothing in this list should be done in one pass. Each row is its own change, verified on its own.
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
# E-1608 Network Scan Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** The E-1608 connection form gets a "Scan network" row that finds boards on every local interface and fills the address or the MAC from a click; the MAC lookup in `E1608Source` stops depending on the default route.
|
||||||
|
|
||||||
|
**Architecture:** A non-blocking `Scan` class in `e1608_link` opens one UDP socket per up IPv4 broadcast interface (bound to the device, or to its address), sends `D` on each and drains replies on `poll()`. The form drives it with a 50 ms GLib timer on the GTK main thread (the project has exactly two threads, so no worker). `discover_broadcast()` is `Scan` plus a blocking wait and replaces the limited-broadcast `discover()` call in the source's MAC lookup.
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4/libadwaita (`AdwExpanderRow`, `AdwActionRow`), POSIX sockets (`getifaddrs`, `SO_BINDTODEVICE`), `tools/mock_e1608.py`, the in-app headless probe under `xvfb-run`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Spec: `docs/superpowers/specs/2026-11-02-e1608-network-scan-design.md` (approved 2026-11-02).
|
||||||
|
- Exactly two threads: no worker thread for the scan; the form polls with `g_timeout_add*`.
|
||||||
|
- Every source file carries the SPDX MIT header (no new source files in this plan).
|
||||||
|
- Text buttons only (no theme icons) on the new controls.
|
||||||
|
- A `subnet` that is given still uses `sweep_targets` + `discover` unchanged.
|
||||||
|
- An interface that cannot be opened or sent on is skipped, never an error; no interfaces is an empty result.
|
||||||
|
- Scan results are never persisted: the session reads and writes only the filled fields.
|
||||||
|
- Baselines: a clean build has exactly 3 warnings (an incremental build shows the 2 `ui_probe.cpp` MathNode ones); `gtk warnings: 0`; no `Gtk-CRITICAL`.
|
||||||
|
- GTK traps (`docs/claude/invariants-gtk-and-chrome.md`): handler signatures are notify `(GObject*, GParamSpec*, gpointer)`, clicked `(GtkButton*, gpointer)`, changed `(GtkEditable*, gpointer)`; handlers behind `Form::alive`; probes use `g_object_ref_sink` + `g_object_unref` on never-parented rows.
|
||||||
|
- Probe code must avoid `x({...}).empty() || y({...}).empty()` (GCC `-Wdangling-pointer`).
|
||||||
|
- Verification runs are short and serial; kill every mock afterwards. Do not push or merge.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- Modify `src/source/e1608/e1608_link.hpp` / `.cpp`: `class Scan`, `discover_broadcast()`, a shared reply parser used by `discover()` too.
|
||||||
|
- Modify `src/source/e1608/e1608_source.cpp`: the MAC lookup with an empty subnet uses `discover_broadcast()`.
|
||||||
|
- Modify `src/source/e1608/e1608_kind.cpp`: the scan row, result rows, fills, timer.
|
||||||
|
- Modify `src/ui_probe.cpp`, `src/ui_app.hpp`, `src/ui.cpp`: the `UDPSCOPE_PROBE_E1608_SCAN` probe.
|
||||||
|
- Modify docs: `README.md`, `docs/claude/{build-and-run,verification,probe-readings,architecture}.md`.
|
||||||
|
|
||||||
|
One design note the spec leaves open: `AdwExpanderRow` can only append rows, so "results right after the scan row" is done by making the scan row itself an `AdwExpanderRow` ("Scan network", a text "Scan" suffix button) at the top of the Connection expander, with the results as its child rows. It reads the same: results sit directly under the scan row.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: The `Scan` class, `discover_broadcast()`, and the MAC lookup
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/source/e1608/e1608_link.hpp` (after `discover`, line ~77)
|
||||||
|
- Modify: `src/source/e1608/e1608_link.cpp` (includes; anonymous namespace; `discover`; new definitions)
|
||||||
|
- Modify: `src/source/e1608/e1608_source.cpp:270-293`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (used by Task 2):
|
||||||
|
```cpp
|
||||||
|
class Scan {
|
||||||
|
public:
|
||||||
|
explicit Scan(int port);
|
||||||
|
Scan(const Scan&) = delete;
|
||||||
|
Scan& operator=(const Scan&) = delete;
|
||||||
|
const std::vector<DeviceInfo>& poll(); // drains non-blocking; cumulative, one entry per MAC
|
||||||
|
std::size_t interfaces() const noexcept; // sockets opened
|
||||||
|
};
|
||||||
|
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms);
|
||||||
|
```
|
||||||
|
- Consumes: existing `Fd`, `DeviceInfo`, `kProductId`, `Clock`, `ms_left`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Declare the API**
|
||||||
|
|
||||||
|
In `e1608_link.hpp`, after the `discover` declaration add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// A broadcast discovery that never blocks. The limited broadcast alone leaves through the default
|
||||||
|
/// route only, so on a host with several interfaces it can miss a board on another one; this opens
|
||||||
|
/// one socket per up, non-loopback IPv4 interface with a broadcast address (bound to the device, or
|
||||||
|
/// to the interface address when binding to the device is refused), sends `D` to
|
||||||
|
/// 255.255.255.255 on each, and collects the replies as `poll()` is called. The caller owns the
|
||||||
|
/// clock. An interface that cannot be opened or sent on is skipped; no interface is an empty scan.
|
||||||
|
class Scan {
|
||||||
|
public:
|
||||||
|
explicit Scan(int port);
|
||||||
|
Scan(const Scan&) = delete;
|
||||||
|
Scan& operator=(const Scan&) = delete;
|
||||||
|
/// Drains every socket without blocking. The devices found so far: E-1608s only, one per MAC.
|
||||||
|
const std::vector<DeviceInfo>& poll();
|
||||||
|
/// How many interfaces a probe was sent on.
|
||||||
|
std::size_t interfaces() const noexcept { return socks_.size(); }
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<Fd> socks_;
|
||||||
|
std::vector<DeviceInfo> found_;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `Scan` plus a blocking wait of `wait_ms`: what a lookup by MAC with no subnet uses.
|
||||||
|
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Share the reply parser**
|
||||||
|
|
||||||
|
In `e1608_link.cpp` add includes `#include <ifaddrs.h>`, `#include <net/if.h>`, `#include <thread>` (keep the list alphabetical with the existing ones). Inside the existing anonymous namespace, just before its closing `} // namespace` (after `tcp_connect`), add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// A `D` reply (at least 29 bytes, first byte `D`, our product id). True when it added a board
|
||||||
|
/// that `found` did not already hold (one entry per MAC).
|
||||||
|
bool take_reply(const std::uint8_t* buf, ssize_t n, const sockaddr_in& from,
|
||||||
|
std::vector<DeviceInfo>& found) {
|
||||||
|
if (n < 29 || buf[0] != 'D') return false;
|
||||||
|
DeviceInfo info;
|
||||||
|
info.product_id = static_cast<std::uint16_t>(buf[7] | (buf[8] << 8));
|
||||||
|
if (info.product_id != kProductId) return false;
|
||||||
|
std::copy(buf + 1, buf + 7, info.mac.begin());
|
||||||
|
for (const DeviceInfo& d : found) {
|
||||||
|
if (d.mac == info.mac) return false;
|
||||||
|
}
|
||||||
|
char ip[INET_ADDRSTRLEN] = {};
|
||||||
|
::inet_ntop(AF_INET, &from.sin_addr, ip, sizeof ip);
|
||||||
|
info.ip = ip;
|
||||||
|
found.push_back(info);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `discover()`, replace the block from `if (n < 29 || buf[0] != 'D') continue;` through `found.push_back(info);` (the lines that build `info`, test the MAC for a duplicate and push) with the single call:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
take_reply(buf, n, from, found);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `Scan` and `discover_broadcast`**
|
||||||
|
|
||||||
|
After `discover()` (before the `// --- Link` banner) add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
Scan::Scan(int port) {
|
||||||
|
ifaddrs* list = nullptr;
|
||||||
|
if (::getifaddrs(&list) != 0) return;
|
||||||
|
sockaddr_in to{};
|
||||||
|
to.sin_family = AF_INET;
|
||||||
|
to.sin_port = htons(static_cast<std::uint16_t>(port));
|
||||||
|
to.sin_addr.s_addr = htonl(INADDR_BROADCAST);
|
||||||
|
const std::uint8_t probe = 'D';
|
||||||
|
std::vector<std::string> done; // an interface with several addresses needs one probe, not several
|
||||||
|
for (const ifaddrs* a = list; a != nullptr; a = a->ifa_next) {
|
||||||
|
if (a->ifa_addr == nullptr || a->ifa_addr->sa_family != AF_INET) continue;
|
||||||
|
const unsigned fl = a->ifa_flags;
|
||||||
|
if ((fl & IFF_UP) == 0 || (fl & IFF_LOOPBACK) != 0 || (fl & IFF_BROADCAST) == 0) continue;
|
||||||
|
const std::string name = a->ifa_name;
|
||||||
|
if (std::find(done.begin(), done.end(), name) != done.end()) continue;
|
||||||
|
Fd s(::socket(AF_INET, SOCK_DGRAM | SOCK_NONBLOCK, 0));
|
||||||
|
if (!s.valid()) continue;
|
||||||
|
const int one = 1;
|
||||||
|
::setsockopt(s.get(), SOL_SOCKET, SO_BROADCAST, &one, sizeof one);
|
||||||
|
if (::setsockopt(s.get(), SOL_SOCKET, SO_BINDTODEVICE, name.c_str(),
|
||||||
|
static_cast<socklen_t>(name.size())) != 0) {
|
||||||
|
sockaddr_in me = *reinterpret_cast<const sockaddr_in*>(a->ifa_addr);
|
||||||
|
me.sin_port = 0;
|
||||||
|
if (::bind(s.get(), reinterpret_cast<const sockaddr*>(&me), sizeof me) != 0) continue;
|
||||||
|
}
|
||||||
|
if (::sendto(s.get(), &probe, 1, MSG_NOSIGNAL, reinterpret_cast<const sockaddr*>(&to),
|
||||||
|
sizeof to) != 1)
|
||||||
|
continue;
|
||||||
|
done.push_back(name);
|
||||||
|
socks_.push_back(std::move(s));
|
||||||
|
}
|
||||||
|
::freeifaddrs(list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::vector<DeviceInfo>& Scan::poll() {
|
||||||
|
for (const Fd& s : socks_) {
|
||||||
|
for (;;) {
|
||||||
|
std::uint8_t buf[128];
|
||||||
|
sockaddr_in from{};
|
||||||
|
socklen_t flen = sizeof from;
|
||||||
|
const ssize_t n = ::recvfrom(s.get(), buf, sizeof buf, MSG_DONTWAIT,
|
||||||
|
reinterpret_cast<sockaddr*>(&from), &flen);
|
||||||
|
if (n < 0) break; // nothing more on this socket for now
|
||||||
|
take_reply(buf, n, from, found_);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found_;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<DeviceInfo> discover_broadcast(int port, int wait_ms) {
|
||||||
|
Scan scan(port);
|
||||||
|
const auto deadline = Clock::now() + std::chrono::milliseconds(wait_ms);
|
||||||
|
while (ms_left(deadline) > 0) {
|
||||||
|
scan.poll();
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(10));
|
||||||
|
}
|
||||||
|
return scan.poll();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Use it in the source's lookup**
|
||||||
|
|
||||||
|
In `e1608_source.cpp` `connect_and_start()`, replace
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const std::vector<std::string> targets =
|
||||||
|
host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{host};
|
||||||
|
const std::vector<DeviceInfo> found =
|
||||||
|
discover(targets, cfg_.port, std::min(cfg_.timeout_ms, 1500));
|
||||||
|
```
|
||||||
|
|
||||||
|
with
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const std::vector<std::string> targets =
|
||||||
|
host.empty() ? sweep_targets(cfg_.subnet) : std::vector<std::string>{host};
|
||||||
|
const int wait_ms = std::min(cfg_.timeout_ms, 1500);
|
||||||
|
// No address and no subnet: the limited broadcast, sent on every interface rather than only
|
||||||
|
// the default route's.
|
||||||
|
const bool broadcast =
|
||||||
|
host.empty() && targets.size() == 1 && targets[0] == "255.255.255.255";
|
||||||
|
const std::vector<DeviceInfo> found =
|
||||||
|
broadcast ? discover_broadcast(cfg_.port, wait_ms) : discover(targets, cfg_.port, wait_ms);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build**
|
||||||
|
|
||||||
|
Run: `cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done`
|
||||||
|
Expected: no `error:`; only the 2 baseline `ui_probe.cpp` MathNode warnings (incremental) -- nothing new from `e1608_link.cpp` or `e1608_source.cpp`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify the MAC lookup end to end against the mock**
|
||||||
|
|
||||||
|
The mock answers a broadcast `D` on UDP 54211. A run with only a `mac` option (no address, no subnet) must find it and stream:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_e1608.py --port 54211 > /tmp/mock-scan.log 2>&1 &
|
||||||
|
MOCK=$!
|
||||||
|
UDPSCOPE_PROBE_E1608=1500 UDPSCOPE_EXIT_AFTER_MS=4000 xvfb-run -a ./build/udpscope --source e1608 \
|
||||||
|
--source-opt mac=00:80:2f:00:16:08 --source-opt scan_rate=1000 2>&1 | grep -E "^\[e1608\] (stream|state)"
|
||||||
|
kill $MOCK
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `[e1608] stream: connected=1 ... ok=1` and `state: connects=1 message=''`.
|
||||||
|
If it reads `connected=0` with `no E-1608 with MAC ... answered`: the sandbox did not loop the broadcast back to the mock. Check with `ip -4 -o addr` that an up non-loopback interface with a broadcast address exists. If one does and it still fails, report that to the user as a finding; do not paper over it.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/source/e1608/e1608_link.hpp src/source/e1608/e1608_link.cpp src/source/e1608/e1608_source.cpp
|
||||||
|
git commit -m "e1608: discover boards on every interface, not only the default route's"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: The form's "Scan network" row, results and fills
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/source/e1608/e1608_kind.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `Scan`, `DeviceInfo`, `format_mac` (Task 1 / existing, via `e1608_source.hpp`'s includes of `e1608_link.hpp`; add the include if the build says otherwise).
|
||||||
|
- Produces (used by Task 3's probe), by widget name: the scan row `AdwExpanderRow` named `e1608-scan-row`; its suffix `GtkButton` named `e1608-scan`; each result an `AdwActionRow` named `e1608-result` (title IP, subtitle MAC, `activated` fills the address) holding a suffix button named `e1608-use-mac`. Subtitle texts: `Scanning...`, `1 board found`, `N boards found`, `No board found`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the state and `fill()` to `Form`**
|
||||||
|
|
||||||
|
In `struct Form`, after `bool alive = true;...` add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
GtkWidget* scan_row = nullptr; ///< the "Scan network" expander; the results are its rows
|
||||||
|
std::vector<GtkWidget*> results; ///< the result rows of the current scan
|
||||||
|
std::unique_ptr<Scan> scan; ///< non-null while a scan is running
|
||||||
|
guint timer = 0; ///< the GLib source polling `scan`, 0 when none
|
||||||
|
int scan_ms = 0; ///< time spent in the running scan
|
||||||
|
```
|
||||||
|
|
||||||
|
and, after `write(...)`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// A result click: puts one way of naming the board in and the other two fields out, under the
|
||||||
|
/// `updating` guard so the dialog is told once.
|
||||||
|
void fill(const std::string& addr, const std::string& mac_text) {
|
||||||
|
if (!alive) return;
|
||||||
|
updating = true;
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(address), addr.c_str());
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(mac), mac_text.c_str());
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(subnet), "");
|
||||||
|
updating = false;
|
||||||
|
if (changed && *changed) (*changed)();
|
||||||
|
}
|
||||||
|
|
||||||
|
void stop_scan() {
|
||||||
|
if (timer != 0) g_source_remove(timer);
|
||||||
|
timer = 0;
|
||||||
|
scan.reset();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `#include <vector>` to the includes if absent.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Destroy hook stops the timer**
|
||||||
|
|
||||||
|
Replace `on_form_destroy` with:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void on_form_destroy(GtkWidget*, gpointer user) {
|
||||||
|
Form& f = **static_cast<FormPtr*>(user);
|
||||||
|
f.alive = false;
|
||||||
|
f.stop_scan(); // the poll must not outlive the rows it writes to
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: The scan functions**
|
||||||
|
|
||||||
|
After `hook_entry(...)` and before `class E1608Kind`, add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
constexpr int kScanMs = 1500; ///< how long a scan listens
|
||||||
|
constexpr int kTickMs = 50; ///< how often it drains the sockets
|
||||||
|
|
||||||
|
/// A result row's two ways to name the board, with the form they fill.
|
||||||
|
struct Pick {
|
||||||
|
FormPtr form;
|
||||||
|
std::string ip, mac;
|
||||||
|
};
|
||||||
|
|
||||||
|
void free_pick(gpointer d, GClosure*) { delete static_cast<Pick*>(d); }
|
||||||
|
|
||||||
|
void on_pick_address(AdwActionRow*, gpointer user) {
|
||||||
|
Pick& p = *static_cast<Pick*>(user);
|
||||||
|
p.form->fill(p.ip, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_pick_mac(GtkButton*, gpointer user) {
|
||||||
|
Pick& p = *static_cast<Pick*>(user);
|
||||||
|
p.form->fill("", p.mac);
|
||||||
|
}
|
||||||
|
|
||||||
|
void add_result(const FormPtr& f, const DeviceInfo& d) {
|
||||||
|
const std::string mac = format_mac(d.mac);
|
||||||
|
GtkWidget* row = adw_action_row_new();
|
||||||
|
gtk_widget_set_name(row, "e1608-result");
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(row), d.ip.c_str());
|
||||||
|
adw_action_row_set_subtitle(ADW_ACTION_ROW(row), mac.c_str());
|
||||||
|
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), TRUE);
|
||||||
|
GtkWidget* use_mac = gtk_button_new_with_label("Use MAC");
|
||||||
|
gtk_widget_set_name(use_mac, "e1608-use-mac");
|
||||||
|
gtk_widget_set_valign(use_mac, GTK_ALIGN_CENTER);
|
||||||
|
adw_action_row_add_suffix(ADW_ACTION_ROW(row), use_mac);
|
||||||
|
g_signal_connect_data(row, "activated", G_CALLBACK(on_pick_address),
|
||||||
|
new Pick{f, d.ip, mac}, free_pick, static_cast<GConnectFlags>(0));
|
||||||
|
g_signal_connect_data(use_mac, "clicked", G_CALLBACK(on_pick_mac), new Pick{f, d.ip, mac},
|
||||||
|
free_pick, static_cast<GConnectFlags>(0));
|
||||||
|
adw_expander_row_add_row(ADW_EXPANDER_ROW(f->scan_row), row);
|
||||||
|
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f->scan_row), TRUE);
|
||||||
|
f->results.push_back(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_scan_status(Form& f, const std::string& text) {
|
||||||
|
adw_expander_row_set_subtitle(ADW_EXPANDER_ROW(f.scan_row), text.c_str());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 50 ms poll of the running scan. The rows appear as boards answer; at `kScanMs` the status is
|
||||||
|
/// settled and the scan (its sockets) is released.
|
||||||
|
gboolean on_scan_tick(gpointer user) {
|
||||||
|
const FormPtr& fp = *static_cast<FormPtr*>(user);
|
||||||
|
Form& f = *fp;
|
||||||
|
if (!f.alive || !f.scan) {
|
||||||
|
f.timer = 0;
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
const std::vector<DeviceInfo> found = f.scan->poll();
|
||||||
|
while (f.results.size() < found.size()) add_result(fp, found[f.results.size()]);
|
||||||
|
f.scan_ms += kTickMs;
|
||||||
|
if (f.scan_ms < kScanMs) return G_SOURCE_CONTINUE;
|
||||||
|
if (found.empty())
|
||||||
|
set_scan_status(f, "No board found");
|
||||||
|
else if (found.size() == 1)
|
||||||
|
set_scan_status(f, "1 board found");
|
||||||
|
else
|
||||||
|
set_scan_status(f, std::to_string(found.size()) + " boards found");
|
||||||
|
f.scan.reset();
|
||||||
|
f.timer = 0;
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
void free_form_ptr(gpointer d) { delete static_cast<FormPtr*>(d); }
|
||||||
|
|
||||||
|
/// A new scan on the Port field's value: the previous results go, a running scan is replaced.
|
||||||
|
void start_scan(const FormPtr& fp) {
|
||||||
|
Form& f = *fp;
|
||||||
|
if (!f.alive) return;
|
||||||
|
f.stop_scan();
|
||||||
|
for (GtkWidget* r : f.results) adw_expander_row_remove(ADW_EXPANDER_ROW(f.scan_row), r);
|
||||||
|
f.results.clear();
|
||||||
|
adw_expander_row_set_expanded(ADW_EXPANDER_ROW(f.scan_row), FALSE);
|
||||||
|
f.scan = std::make_unique<Scan>(static_cast<int>(value_of(f.port)));
|
||||||
|
f.scan_ms = 0;
|
||||||
|
set_scan_status(f, "Scanning...");
|
||||||
|
f.timer = g_timeout_add_full(G_PRIORITY_DEFAULT, kTickMs, on_scan_tick, new FormPtr(fp),
|
||||||
|
free_form_ptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_scan_clicked(GtkButton*, gpointer user) {
|
||||||
|
start_scan(*static_cast<FormPtr*>(user));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`f.stop_scan()` removes the timer; `g_source_remove` fires `free_form_ptr`, which only drops one `FormPtr` copy, never the last. `on_scan_tick` never calls `g_source_remove` on itself: it zeroes `timer` and returns `G_SOURCE_REMOVE`.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the row in `make_config_form()`**
|
||||||
|
|
||||||
|
After the `f->ao_init[1] = ...` line, add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
f->scan_row = adw_expander_row_new();
|
||||||
|
gtk_widget_set_name(f->scan_row, "e1608-scan-row");
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(f->scan_row), "Scan network");
|
||||||
|
GtkWidget* scan_button = gtk_button_new_with_label("Scan");
|
||||||
|
gtk_widget_set_name(scan_button, "e1608-scan");
|
||||||
|
gtk_widget_set_valign(scan_button, GTK_ALIGN_CENTER);
|
||||||
|
adw_expander_row_add_suffix(ADW_EXPANDER_ROW(f->scan_row), scan_button);
|
||||||
|
g_signal_connect_data(scan_button, "clicked", G_CALLBACK(on_scan_clicked), new FormPtr(f),
|
||||||
|
free_form, static_cast<GConnectFlags>(0));
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the Connection group to start with the scan row:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
GtkWidget* conn = expander("Connection", {f->scan_row, f->address, f->mac, f->subnet, f->port,
|
||||||
|
f->code, f->timeout});
|
||||||
|
```
|
||||||
|
|
||||||
|
Update the description string to:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
out.description =
|
||||||
|
"Give an address, or a MAC address to look up (optionally with a subnet such as 192.168.1), "
|
||||||
|
"or scan the network for boards";
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build, then check the existing form round trip and warnings**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done
|
||||||
|
UDPSCOPE_PROBE_SOURCE_KINDS=1000 UDPSCOPE_EXIT_AFTER_MS=2500 xvfb-run -a ./build/udpscope \
|
||||||
|
--source probe --source-opt rate=500 2>&1 | grep -E "form e1608|gtk warnings|Gtk-CRITICAL"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no `error:`, only the 2 baseline warnings; `[source-kinds] form e1608: round_trip=1 valid=1`; `gtk warnings: 0`; no `Gtk-CRITICAL`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/source/e1608/e1608_kind.cpp
|
||||||
|
git commit -m "e1608: a Scan network row in the connection form, with click-to-fill results"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: The scan probe, docs, and final verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_probe.cpp` (after `e1608_probe_reconnect`, ~line 2459)
|
||||||
|
- Modify: `src/ui_app.hpp:1874-1875`
|
||||||
|
- Modify: `src/ui.cpp:6031-6033`
|
||||||
|
- Modify: `README.md` (E-1608 paragraph, lines ~118-146), `docs/claude/build-and-run.md:118-130`, `docs/claude/verification.md` (after the `UDPSCOPE_PROBE_E1608_RECONNECT` bullet, ~line 443), `docs/claude/probe-readings.md` (`## [e1608]` section, ~line 1848-1890), `docs/claude/architecture.md:45-48`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the widget names from Task 2; `find_named()`, `find_kind()` in `ui_probe.cpp`.
|
||||||
|
- Produces: env var `UDPSCOPE_PROBE_E1608_SCAN=<ms>` and optional `UDPSCOPE_PROBE_E1608_SCAN_MAC` (default `00:80:2f:00:16:08`, the mock's); one stderr line `[e1608] scan: ...`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Declare and register the probe**
|
||||||
|
|
||||||
|
In `ui_app.hpp`, after `gboolean e1608_probe_resumed(gpointer user);` add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// The E-1608 form's "Scan network" row: presses Scan against the mock, then (phases `_fill`,
|
||||||
|
/// `_rescan`) clicks a result to fill the address, "Use MAC" to fill the MAC, scans again and checks
|
||||||
|
/// the results were replaced, not added to.
|
||||||
|
gboolean e1608_scan_probe(gpointer user);
|
||||||
|
gboolean e1608_scan_probe_fill(gpointer user);
|
||||||
|
gboolean e1608_scan_probe_rescan(gpointer user);
|
||||||
|
```
|
||||||
|
|
||||||
|
In `ui.cpp`, after the `UDPSCOPE_PROBE_E1608_RECONNECT` block add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN")) {
|
||||||
|
g_timeout_add(static_cast<guint>(std::atoi(env)), e1608_scan_probe, &app);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the probe**
|
||||||
|
|
||||||
|
In `ui_probe.cpp`, after `e1608_probe_reconnect`, add (this is outside the file's anonymous namespace, like its neighbours; the helpers it uses, `find_named` and `find_kind`, are visible):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// The scan probe's state, alive from phase 1 to phase 3.
|
||||||
|
struct E1608ScanProbe {
|
||||||
|
ConfigForm form;
|
||||||
|
int changes = 0; ///< how many times the form told the dialog it changed
|
||||||
|
std::string want_mac; ///< the board the probe expects (the mock's by default)
|
||||||
|
std::string status; ///< the scan row's subtitle, as read at the end of the first scan
|
||||||
|
std::string ip; ///< the expected board's address, as listed
|
||||||
|
std::size_t first = 0; ///< result rows after the first scan
|
||||||
|
bool listed = false; ///< the expected board was among them
|
||||||
|
bool fill_addr = false;
|
||||||
|
bool fill_mac = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
E1608ScanProbe* g_scan_probe = nullptr;
|
||||||
|
|
||||||
|
void collect_named(GtkWidget* root, const char* name, std::vector<GtkWidget*>& out) {
|
||||||
|
if (root == nullptr) return;
|
||||||
|
if (std::strcmp(gtk_widget_get_name(root), name) == 0) out.push_back(root);
|
||||||
|
for (GtkWidget* c = gtk_widget_get_first_child(root); c != nullptr;
|
||||||
|
c = gtk_widget_get_next_sibling(c))
|
||||||
|
collect_named(c, name, out);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string scan_status(E1608ScanProbe& p) {
|
||||||
|
GtkWidget* row = find_named(p.form.rows[0], "e1608-scan-row");
|
||||||
|
const char* t = row != nullptr ? adw_expander_row_get_subtitle(ADW_EXPANDER_ROW(row)) : nullptr;
|
||||||
|
return t != nullptr ? t : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A form holding stale values in all three naming fields, so a fill is seen to clear the others.
|
||||||
|
void scan_probe_stale(E1608ScanProbe& p) {
|
||||||
|
SourceConfig c;
|
||||||
|
c.set("address", "1.2.3.4");
|
||||||
|
c.set("mac", "aa:bb:cc:dd:ee:ff");
|
||||||
|
c.set("subnet", "10.0.0");
|
||||||
|
p.form.write(c);
|
||||||
|
p.changes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void scan_probe_click(E1608ScanProbe& p) {
|
||||||
|
GtkWidget* button = find_named(p.form.rows[0], "e1608-scan");
|
||||||
|
if (button != nullptr) g_signal_emit_by_name(button, "clicked");
|
||||||
|
}
|
||||||
|
|
||||||
|
void scan_probe_cleanup() {
|
||||||
|
if (g_scan_probe == nullptr) return;
|
||||||
|
for (GtkWidget* row : g_scan_probe->form.rows) {
|
||||||
|
g_object_ref_sink(row);
|
||||||
|
g_object_unref(row);
|
||||||
|
}
|
||||||
|
delete g_scan_probe;
|
||||||
|
g_scan_probe = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
/// Phase 3, 2 s after the second Scan: the results were replaced (same count), and the one line.
|
||||||
|
gboolean e1608_scan_probe_rescan(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
E1608ScanProbe& p = *g_scan_probe;
|
||||||
|
std::vector<GtkWidget*> rows;
|
||||||
|
collect_named(p.form.rows[0], "e1608-result", rows);
|
||||||
|
const bool replaced = rows.size() == p.first;
|
||||||
|
const std::string status = scan_status(p);
|
||||||
|
const bool status_ok = status.find("found") != std::string::npos && status.rfind("No", 0) != 0;
|
||||||
|
const bool ok = p.listed && p.fill_addr && p.fill_mac && replaced && status_ok;
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[e1608] scan: found=%zu ip=%s mac=%s status='%s' fill_addr=%d fill_mac=%d "
|
||||||
|
"rescan_replaced=%d ok=%d\n",
|
||||||
|
p.first, p.ip.c_str(), p.want_mac.c_str(), p.status.c_str(), p.fill_addr ? 1 : 0,
|
||||||
|
p.fill_mac ? 1 : 0, replaced ? 1 : 0, ok ? 1 : 0);
|
||||||
|
if (!p.listed)
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[e1608] scan: %s did not answer; no up broadcast interface reaches it, or the "
|
||||||
|
"broadcast was refused (this run proves nothing about the scan)\n",
|
||||||
|
p.want_mac.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
scan_probe_cleanup();
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 2, 2 s after the first Scan: the expected board is listed, a result click fills the address
|
||||||
|
/// only, "Use MAC" fills the MAC only (each telling the dialog exactly once), then Scan again.
|
||||||
|
gboolean e1608_scan_probe_fill(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr || g_scan_probe == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
E1608ScanProbe& p = *g_scan_probe;
|
||||||
|
p.status = scan_status(p);
|
||||||
|
std::vector<GtkWidget*> rows;
|
||||||
|
collect_named(p.form.rows[0], "e1608-result", rows);
|
||||||
|
p.first = rows.size();
|
||||||
|
GtkWidget* mine = nullptr;
|
||||||
|
for (GtkWidget* r : rows) {
|
||||||
|
const char* sub = adw_action_row_get_subtitle(ADW_ACTION_ROW(r));
|
||||||
|
if (sub != nullptr && p.want_mac == sub) mine = r;
|
||||||
|
}
|
||||||
|
p.listed = mine != nullptr;
|
||||||
|
if (mine != nullptr) {
|
||||||
|
const char* title = adw_preferences_row_get_title(ADW_PREFERENCES_ROW(mine));
|
||||||
|
p.ip = title != nullptr ? title : "";
|
||||||
|
|
||||||
|
scan_probe_stale(p);
|
||||||
|
g_signal_emit_by_name(mine, "activated");
|
||||||
|
const SourceConfig by_addr = p.form.read();
|
||||||
|
p.fill_addr = by_addr.get("address") == p.ip && by_addr.get("mac").empty() &&
|
||||||
|
by_addr.get("subnet").empty() && p.changes == 1;
|
||||||
|
|
||||||
|
scan_probe_stale(p);
|
||||||
|
GtkWidget* use = find_named(mine, "e1608-use-mac");
|
||||||
|
if (use != nullptr) g_signal_emit_by_name(use, "clicked");
|
||||||
|
const SourceConfig by_mac = p.form.read();
|
||||||
|
p.fill_mac = by_mac.get("mac") == p.want_mac && by_mac.get("address").empty() &&
|
||||||
|
by_mac.get("subnet").empty() && p.changes == 1;
|
||||||
|
}
|
||||||
|
scan_probe_click(p);
|
||||||
|
g_timeout_add(2000, e1608_scan_probe_rescan, a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 1: the form, Scan pressed (the row must read `Scanning...` at once), phase 2 in 2 s.
|
||||||
|
gboolean e1608_scan_probe(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
SourceKind* k = find_kind("e1608");
|
||||||
|
if (k == nullptr) {
|
||||||
|
std::fprintf(stderr, "[e1608] scan: no e1608 kind\n");
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
g_scan_probe = new E1608ScanProbe;
|
||||||
|
E1608ScanProbe& p = *g_scan_probe;
|
||||||
|
p.form = k->make_config_form();
|
||||||
|
const char* env = g_getenv("UDPSCOPE_PROBE_E1608_SCAN_MAC");
|
||||||
|
p.want_mac = env != nullptr ? env : "00:80:2f:00:16:08";
|
||||||
|
p.form.on_change([]() {
|
||||||
|
if (g_scan_probe != nullptr) ++g_scan_probe->changes;
|
||||||
|
});
|
||||||
|
scan_probe_click(p);
|
||||||
|
const std::string now = scan_status(p);
|
||||||
|
if (now != "Scanning...")
|
||||||
|
std::fprintf(stderr, "[e1608] scan: status right after Scan reads '%s', expected 'Scanning...'\n",
|
||||||
|
now.c_str());
|
||||||
|
g_timeout_add(2000, e1608_scan_probe_fill, a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes for the implementer: `SourceConfig::get` returns `std::string` (the existing probe calls `f.read().get("signals") == "..."`); `find_kind`, `SourceConfig` and `ConfigForm` are already used in this file. Do not write `x({...}).empty() || y({...}).empty()`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build and run against the mock**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build -j8 2>&1 | grep -E "warning:|error:" ; echo done
|
||||||
|
python3 tools/mock_e1608.py --port 54211 > /tmp/mock-scan.log 2>&1 &
|
||||||
|
MOCK=$!
|
||||||
|
UDPSCOPE_PROBE_E1608_SCAN=1000 UDPSCOPE_EXIT_AFTER_MS=6500 xvfb-run -a ./build/udpscope \
|
||||||
|
--source e1608 --source-opt address=127.0.0.1 --source-opt scan_rate=1000 2>&1 \
|
||||||
|
| grep -E "^\[e1608\] scan|gtk warnings|Gtk-CRITICAL"
|
||||||
|
kill $MOCK
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `[e1608] scan: found=1 ip=<a local address> mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1`; `gtk warnings: 0`; no `Gtk-CRITICAL`. `found=2` or more is fine if a real board is on the network (status then reads `N boards found`; the line still needs `ok=1`).
|
||||||
|
If `ok=0` and the second line says the board did not answer, check Task 1 Step 6's loopback note, and tell the user honestly; do not mark this step passed.
|
||||||
|
If a flag other than `listed` reads 0, debug that flag (the fill reads the form back; `changes` must be exactly 1 per fill).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Clean-build warning count**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rm -rf /tmp/scan-clean && cmake -S . -B /tmp/scan-clean >/dev/null && cmake --build /tmp/scan-clean -j8 2>&1 | grep -c "warning:"; rm -rf /tmp/scan-clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `3`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Docs**
|
||||||
|
|
||||||
|
- `README.md`, E-1608 paragraph (line ~119): after "...(optionally with the subnet to sweep)," add the scan: the sentence "A **Scan network** row at the top of the form probes every network interface of the host for boards (about a second and a half), lists each one by address and MAC, and a click on a result fills the address, or **Use MAC** fills the MAC and leaves the address empty. Results are not saved: only the filled field is." Also add one sentence that a MAC with no subnet is now looked up on every interface, not only the default route's, because a single limited broadcast leaves through the default route alone and missed a board on another interface (found on a real board 2026-11-02, where the board was on `net0` and the default route was `wlan0`).
|
||||||
|
- `docs/claude/build-and-run.md` line ~125: change "a MAC with no `address` is looked up by broadcast" to "...looked up by a broadcast sent on every interface (`Scan`), or by a sweep of `subnet`".
|
||||||
|
- `docs/claude/verification.md`: after the `UDPSCOPE_PROBE_E1608_RECONNECT=8000` bullet add a bullet for `UDPSCOPE_PROBE_E1608_SCAN=1000` with `UDPSCOPE_EXIT_AFTER_MS=6500`, run as `--source e1608 --source-opt address=127.0.0.1` against the mock on its default port 54211 (the form scans that port), optional `UDPSCOPE_PROBE_E1608_SCAN_MAC` for a real board; one `[e1608] scan:` line; note it needs a non-loopback up interface with a broadcast address, and that the line says so when the board did not answer.
|
||||||
|
- `docs/claude/probe-readings.md`, `## [e1608]`: add the sample line `[e1608] scan: found=1 ip=172.21.121.113 mac=00:80:2f:00:16:08 status='1 board found' fill_addr=1 fill_mac=1 rescan_replaced=1 ok=1` to the block, and one bullet per field: `found` (result rows after the first scan; 0 with the "did not answer" second line is an environment without a reachable broadcast interface, not a pass), `status` (the row subtitle; "No board found" with a mock running is the probe sockets not reaching it), `fill_addr=0` (a result click did not set only the address, or told the dialog other than once), `fill_mac=0` (same for Use MAC), `rescan_replaced=0` (the results of the first scan were kept or doubled: `start_scan` did not remove them).
|
||||||
|
- `docs/claude/architecture.md` line ~45: in the E-1608 three-layers bullet, add that discovery is `Scan` (one socket per interface, polled by the caller; the form polls it from a GLib timer on the main thread, the source's lookup through `discover_broadcast`).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui_probe.cpp src/ui_app.hpp src/ui.cpp README.md docs/claude
|
||||||
|
git commit -m "e1608: probe and docs for the network scan"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Manual run on the real board (only if it is attached)**
|
||||||
|
|
||||||
|
Check `ip -4 -o addr` for the board's interface and `ping`-free: simply run the probe without the mock, naming the board's MAC (the user knows it; the MAC printed by the earlier hardware run was on `net0`, address 169.254.88.75):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
UDPSCOPE_PROBE_E1608_SCAN=1000 UDPSCOPE_PROBE_E1608_SCAN_MAC=<board mac> UDPSCOPE_EXIT_AFTER_MS=6500 \
|
||||||
|
xvfb-run -a ./build/udpscope --source e1608 --source-opt address=127.0.0.1 2>&1 | grep -E "^\[e1608\] scan"
|
||||||
|
```
|
||||||
|
|
||||||
|
If it finds the board (`listed`, `ok=1`), add one sentence with the date to the README's E-1608 paragraph ("the network scan was checked against a real board on <date>") and commit `docs: record the scan check on a real board`. If the board is not attached or the MAC is unknown, say so; claim nothing.
|
||||||
@@ -0,0 +1,950 @@
|
|||||||
|
# Badge bar, slide-over and dock -- step 1 (the shell) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Replace the sidebar's tab switcher with a plot-edge slide-over panel reached from a badge bar under the plot (Trigger and Source badges), keeping Measure and Signals reachable in a temporary always-on dock, so the app is usable after every task.
|
||||||
|
|
||||||
|
**Architecture:** Two new units, `SlideOver` (`src/ui_slideover.cpp`, a `GtkRevealer` + `GtkStack` placed by margins as one opaque `GtkOverlay` child above the plot) and `BadgeBar` (`src/ui_badges.cpp`, a fixed-height row between the plot and the status line). The existing trigger form and the source kind's `SourcePanel` widgets are *reparented* into slide-over pages. The right `AdwOverlaySplitView` sidebar stays, but its `AdwViewSwitcher`/`AdwViewStack` become one scroller holding the signal list, the cursors and the measurements. The left events pane and everything else is untouched (spec step 4).
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4, libadwaita >= 1.8 (`AdwToggleGroup` is 1.7+), CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui.cpp` / `src/ui_probe.cpp` probes, never a separate harness.
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` (read it first). Branch: `feature/ui-badges-dock`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
Copied from the spec and the repository's rules; every task includes them.
|
||||||
|
|
||||||
|
- **Layout must not depend on content** (`docs/claude/invariants-plot-and-layout.md`): the dock has a fixed width, the badge bar a fixed height, the slide-over overlays the plot and never resizes it. Rows that appear by trigger condition appear inside the slide-over's scroller.
|
||||||
|
- **A `GtkOverlay` child is picked wherever its allocation is**: the slide-over is one opaque widget aligned to the end edge by `halign=END` and a 360 px width, never a full-area transparent child, so pan, drop and hover on the rest of the plot are untouched.
|
||||||
|
- The header's Trigger / Cursors / Pause toggles keep their two-way mirroring with the panels.
|
||||||
|
- `SourcePanel{id, title, widget}` is unchanged: the E-1608 and Flat UDP panels need no change.
|
||||||
|
- Page ids: `trigger`, `signal:<name>`, `signals`, `measure`, `cursors`, `events`, `source-<id>`. (Step 1 implements `trigger` and `source-<id>` only.)
|
||||||
|
- The open slide-over page is **not** saved. The session file drops `side_tab`; an old file's `side_tab` is ignored.
|
||||||
|
- Esc or a click outside closes the slide-over; one page at a time; each page has its own scroller.
|
||||||
|
- "Disable, do not hide": the dock is shown but insensitive until the stream has signals.
|
||||||
|
- Text buttons only for new controls (no icon-only buttons, no icons in badges).
|
||||||
|
- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`).
|
||||||
|
- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`.
|
||||||
|
- Verification runs are short and serial; kill every mock you start; do not re-run to chase cosmetics. Headless needs `GDK_BACKEND=x11` (the host has `WAYLAND_DISPLAY`) and, for screenshots, `GSK_RENDERER=cairo` + `import -window root`.
|
||||||
|
- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`.
|
||||||
|
|
||||||
|
## Deliberate step-1 deviations from the spec (state them in the final report)
|
||||||
|
|
||||||
|
- The dock keeps the sidebar's **320 px** (spec: ~280). The panels it holds were built for 320; the width change belongs to step 3 when they become compact readouts.
|
||||||
|
- The session file gains the dock's *shown* state only, not a width: the dock's width is fixed, so there is nothing to persist.
|
||||||
|
- The Trigger badge shows `off / armed / triggered` plus `capturing` and `disarmed`, so the label never claims a state the engine is not in.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Create/Modify | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/ui_slideover.cpp` | Create | The slide-over: build, page registry, open/close/toggle, Esc, click-away, `slideover` probe. |
|
||||||
|
| `src/ui_badges.cpp` | Create | The badge bar: Trigger and Source badges, per-tick state, pure `trigger_badge_state()`, `badges` probe. |
|
||||||
|
| `src/ui_app.hpp` | Modify | `SlideOver`/`BadgeBar` structs on `App`, declarations, removal of `side_stack`/`side_switcher`/`side_tab`/`keep_tabs_icon_free`. |
|
||||||
|
| `src/ui/main-window.ui` | Modify | `plot_overlay` around `plot_box`; `badge_bar` row; the sidebar tab switcher/stack replaced by one dock scroller. |
|
||||||
|
| `src/ui_widgets.cpp` | Modify | CSS for badges and the slide-over (`install_css`). |
|
||||||
|
| `src/ui.cpp` | Modify | Build/wire both units in `on_activate`; tick sync; Esc; dock visibility; remove tab code. |
|
||||||
|
| `src/ui_source.cpp` | Modify | Source panels become slide-over pages; `keep_tabs_icon_free` deleted. |
|
||||||
|
| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | Tour/context-help re-pointed (task 5). |
|
||||||
|
| `src/ui_layout.cpp`, `src/layout_store.{hpp,cpp}` | Modify | `side_tab` out, `dock_shown` in. |
|
||||||
|
| `src/shortcuts.{hpp,cpp}`, `src/ui/shortcuts.ui` | Modify | `SideTabMeasure/Signals` -> `ShowMeasure/ShowSignals`. |
|
||||||
|
| `src/ui_probe.cpp` | Modify | New probes; every probe that touched the tab stack re-pointed. |
|
||||||
|
| `CMakeLists.txt` | Modify | Add the two new `.cpp` files to the `udpscope` sources (next to `src/ui_source.cpp`). |
|
||||||
|
| `README.md`, `docs/claude/*.md` | Modify | Task 6. (`USER-GUIDE.md` and the Mallard manual's figures are the spec's step 4.) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: SlideOver unit and the plot overlay (confirms the compositing risk)
|
||||||
|
|
||||||
|
Spec step 1 says: confirm the overlay-above-GL risk *before* anything else is built on it. This task builds the slide-over with a throw-away probe page and proves composite, geometry and input.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ui_slideover.cpp`
|
||||||
|
- Modify: `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_widgets.cpp`, `src/ui_probe.cpp` (or put the probe in `ui_slideover.cpp`; `ui_app.hpp` declares it either way), `CMakeLists.txt`, `docs/claude/verification.md` (probe env var line)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (all in `namespace udpscope::ui_impl`, declared in `ui_app.hpp`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// The slide-over panel's widgets and state (src/ui_slideover.cpp). `page` is the open page's id, empty when closed.
|
||||||
|
struct SlideOver {
|
||||||
|
GtkWidget* overlay = nullptr; ///< the GtkOverlay around the plot (main-window.ui `plot_overlay`)
|
||||||
|
GtkWidget* revealer = nullptr; ///< the one overlay child; halign END, valign FILL, 360 px
|
||||||
|
GtkWidget* title = nullptr; ///< GtkLabel in the panel header
|
||||||
|
GtkWidget* stack = nullptr; ///< GtkStack: one scroller per page
|
||||||
|
GtkWidget* source_switch = nullptr; ///< AdwToggleGroup, shown on a source page when there are >= 2
|
||||||
|
std::vector<std::string> source_pages;
|
||||||
|
std::string page;
|
||||||
|
bool syncing = false; ///< true while we write the toggle group ourselves
|
||||||
|
};
|
||||||
|
constexpr int kSlideOverWidth = 360;
|
||||||
|
void build_slideover(App& app); // once, after the plot_overlay exists
|
||||||
|
bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content);
|
||||||
|
bool slideover_has_page(const App& app, const std::string& id);
|
||||||
|
bool slideover_open(App& app, const std::string& id); // false when the id is unknown
|
||||||
|
void slideover_close(App& app);
|
||||||
|
bool slideover_toggle(App& app, const std::string& id); // open if closed or on another page, else close; false if unknown
|
||||||
|
const std::string& slideover_page(const App& app); // "" when closed
|
||||||
|
bool slideover_escape(App& app); // closes if open; true when it did
|
||||||
|
bool slideover_click_away(App& app, double x, double y); // x,y in overlay coords; closes unless inside the panel; true when it closed
|
||||||
|
gboolean slideover_probe(gpointer user); // UDPSCOPE_PROBE_SLIDEOVER_MS
|
||||||
|
```
|
||||||
|
`App` gains `SlideOver slideover;` and `GtkWidget* plot_overlay = nullptr;`. Task 2 consumes `slideover_add_page`, `slideover_toggle`, `slideover_page`; task 3 consumes `source_pages`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Capture the UDPS baseline from the current HEAD**
|
||||||
|
|
||||||
|
The tabs are about to go, so freeze today's self-check first. Build `HEAD` (`cmake --build build -j`), then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-badges-base.log 2>&1
|
||||||
|
kill $MOCK
|
||||||
|
sed 's/[0-9.]\+/N/g' /tmp/ui-badges-base.log > .superpowers/sdd/ui-badges-baseline-udps.txt
|
||||||
|
grep -c 'Gtk-CRITICAL' /tmp/ui-badges-base.log; grep 'gtk warnings' /tmp/ui-badges-base.log
|
||||||
|
```
|
||||||
|
Expected: `0` Gtk-CRITICAL, `gtk warnings: 0`. Also keep `/tmp/ui-badges-base.log` un-normalised. Every later task diffs its own normalised run against this file and must **explain every differing line** from that task's "allowed baseline changes" list; any other difference is a defect.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Wrap the plot in `plot_overlay` in `src/ui/main-window.ui`**
|
||||||
|
|
||||||
|
In `left_column`, replace the first `<child>` (the bare `plot_box`) with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<child>
|
||||||
|
<!-- The plot, and the slide-over panel that opens over its right edge. The panel is
|
||||||
|
one opaque overlay child placed by `halign=end` and a 360 px width -- never a
|
||||||
|
full-area transparent child, which would be picked wherever its allocation is
|
||||||
|
and swallow the plot's pan, drop and hover (docs/claude/invariants-plot-and-layout.md).
|
||||||
|
The overlay is the only parent change: `plot_box` keeps its id and its content. -->
|
||||||
|
<object class="GtkOverlay" id="plot_overlay">
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
<property name="vexpand">1</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="plot_box">
|
||||||
|
... (the existing plot_box, unchanged) ...
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
```
|
||||||
|
Update the file's header comment: the plot column is `plot_overlay` > `plot_box`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the CSS to `install_css()` (`src/ui_widgets.cpp`)**
|
||||||
|
|
||||||
|
Append to `kCss` before the final `menubutton.udpscope-hint` rules (keep the string a single literal, each rule on its own `"...\n"` line):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// The slide-over panel over the plot's right edge: opaque (it must hide the plot under it),
|
||||||
|
// with an edge and a shadow so it reads as above the plot rather than beside it.
|
||||||
|
".udpscope-slideover { background-color: @window_bg_color; border-left: 1px solid @borders;"
|
||||||
|
" box-shadow: -3px 0 10px rgba(0, 0, 0, 0.35); }\n"
|
||||||
|
".udpscope-slideover-title { font-weight: bold; }\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write `src/ui_slideover.cpp`**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
// The slide-over panel: one panel at a time over the plot's right edge, a page per setup surface.
|
||||||
|
// See docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md.
|
||||||
|
|
||||||
|
#include "ui_app.hpp"
|
||||||
|
|
||||||
|
#include <adwaita.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace ui_impl {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void on_close_clicked(GtkButton*, gpointer user) { slideover_close(*static_cast<App*>(user)); }
|
||||||
|
|
||||||
|
void on_source_switch(GObject* o, GParamSpec*, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
if (app->slideover.syncing) return;
|
||||||
|
const char* name = adw_toggle_group_get_active_name(ADW_TOGGLE_GROUP(o));
|
||||||
|
if (name != nullptr && app->slideover.page != name) slideover_open(*app, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Observes every press on the plot overlay, in the capture phase, and never claims the sequence: the
|
||||||
|
/// plot's own pan/zoom/drop gestures see the press exactly as they did before.
|
||||||
|
void on_press_capture(GtkGestureClick*, int, double x, double y, gpointer user) {
|
||||||
|
slideover_click_away(*static_cast<App*>(user), x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool is_source_page(const std::string& id) { return id.rfind("source-", 0) == 0; }
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void build_slideover(App& app) {
|
||||||
|
SlideOver& s = app.slideover;
|
||||||
|
s.overlay = app.plot_overlay;
|
||||||
|
if (s.overlay == nullptr) return;
|
||||||
|
|
||||||
|
GtkWidget* panel = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
|
gtk_widget_add_css_class(panel, "udpscope-slideover");
|
||||||
|
gtk_widget_set_size_request(panel, kSlideOverWidth, -1);
|
||||||
|
gtk_widget_set_hexpand(panel, FALSE);
|
||||||
|
|
||||||
|
GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||||
|
gtk_widget_set_margin_start(head, 12);
|
||||||
|
gtk_widget_set_margin_end(head, 6);
|
||||||
|
gtk_widget_set_margin_top(head, 6);
|
||||||
|
gtk_widget_set_margin_bottom(head, 6);
|
||||||
|
s.title = gtk_label_new("");
|
||||||
|
gtk_widget_add_css_class(s.title, "udpscope-slideover-title");
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(s.title), 0.0f);
|
||||||
|
gtk_widget_set_hexpand(s.title, TRUE);
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(s.title), PANGO_ELLIPSIZE_END);
|
||||||
|
GtkWidget* close = gtk_button_new_with_label("Close");
|
||||||
|
gtk_widget_add_css_class(close, "flat");
|
||||||
|
g_signal_connect(close, "clicked", G_CALLBACK(on_close_clicked), &app);
|
||||||
|
gtk_box_append(GTK_BOX(head), s.title);
|
||||||
|
gtk_box_append(GTK_BOX(head), close);
|
||||||
|
gtk_box_append(GTK_BOX(panel), head);
|
||||||
|
|
||||||
|
// A source with several panels (the E-1608: Analog out, Digital I/O) gets a segmented switch under
|
||||||
|
// the header; it is hidden unless the open page is a source page and there are two or more.
|
||||||
|
s.source_switch = adw_toggle_group_new();
|
||||||
|
gtk_widget_set_margin_start(s.source_switch, 12);
|
||||||
|
gtk_widget_set_margin_end(s.source_switch, 12);
|
||||||
|
gtk_widget_set_margin_bottom(s.source_switch, 6);
|
||||||
|
gtk_widget_set_visible(s.source_switch, FALSE);
|
||||||
|
g_signal_connect(s.source_switch, "notify::active-name", G_CALLBACK(on_source_switch), &app);
|
||||||
|
gtk_box_append(GTK_BOX(panel), s.source_switch);
|
||||||
|
gtk_box_append(GTK_BOX(panel), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
||||||
|
|
||||||
|
s.stack = gtk_stack_new();
|
||||||
|
gtk_widget_set_vexpand(s.stack, TRUE);
|
||||||
|
gtk_box_append(GTK_BOX(panel), s.stack);
|
||||||
|
|
||||||
|
s.revealer = gtk_revealer_new();
|
||||||
|
gtk_revealer_set_transition_type(GTK_REVEALER(s.revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_LEFT);
|
||||||
|
gtk_revealer_set_transition_duration(GTK_REVEALER(s.revealer), 120);
|
||||||
|
gtk_revealer_set_child(GTK_REVEALER(s.revealer), panel);
|
||||||
|
gtk_widget_set_halign(s.revealer, GTK_ALIGN_END);
|
||||||
|
gtk_widget_set_valign(s.revealer, GTK_ALIGN_FILL);
|
||||||
|
// Closed, it must not be a pick target for the frames of its slide-out.
|
||||||
|
gtk_widget_set_can_target(s.revealer, FALSE);
|
||||||
|
gtk_overlay_add_overlay(GTK_OVERLAY(s.overlay), s.revealer);
|
||||||
|
gtk_overlay_set_clip_overlay(GTK_OVERLAY(s.overlay), s.revealer, TRUE);
|
||||||
|
|
||||||
|
GtkGesture* click = gtk_gesture_click_new();
|
||||||
|
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(click), GTK_PHASE_CAPTURE);
|
||||||
|
g_signal_connect(click, "pressed", G_CALLBACK(on_press_capture), &app);
|
||||||
|
gtk_widget_add_controller(s.overlay, GTK_EVENT_CONTROLLER(click));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content) {
|
||||||
|
SlideOver& s = app.slideover;
|
||||||
|
if (s.stack == nullptr || content == nullptr || id.empty() || slideover_has_page(app, id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// A scroller per page: pages differ in height, and one shared scroller would leave a page scrolled
|
||||||
|
// to somewhere it never asked for. It does not propagate the content's height, so the panel's
|
||||||
|
// height is the plot's and never the page's.
|
||||||
|
GtkWidget* scroller = gtk_scrolled_window_new();
|
||||||
|
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
|
||||||
|
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), content);
|
||||||
|
gtk_stack_add_titled(GTK_STACK(s.stack), scroller, id.c_str(), title.c_str());
|
||||||
|
if (is_source_page(id)) {
|
||||||
|
s.source_pages.push_back(id);
|
||||||
|
AdwToggle* t = adw_toggle_new();
|
||||||
|
adw_toggle_set_name(t, id.c_str());
|
||||||
|
adw_toggle_set_label(t, title.c_str());
|
||||||
|
adw_toggle_group_add(ADW_TOGGLE_GROUP(s.source_switch), t); // takes the reference
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool slideover_has_page(const App& app, const std::string& id) {
|
||||||
|
return app.slideover.stack != nullptr &&
|
||||||
|
gtk_stack_get_child_by_name(GTK_STACK(app.slideover.stack), id.c_str()) != nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool slideover_open(App& app, const std::string& id) {
|
||||||
|
SlideOver& s = app.slideover;
|
||||||
|
if (!slideover_has_page(app, id)) return false;
|
||||||
|
GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str());
|
||||||
|
gtk_stack_set_visible_child(GTK_STACK(s.stack), child);
|
||||||
|
s.page = id;
|
||||||
|
GtkStackPage* sp = gtk_stack_get_page(GTK_STACK(s.stack), child);
|
||||||
|
const char* title = gtk_stack_page_get_title(sp);
|
||||||
|
gtk_label_set_text(GTK_LABEL(s.title), title != nullptr ? title : "");
|
||||||
|
const bool several = is_source_page(id) && s.source_pages.size() >= 2;
|
||||||
|
gtk_widget_set_visible(s.source_switch, several);
|
||||||
|
if (several) {
|
||||||
|
s.syncing = true;
|
||||||
|
adw_toggle_group_set_active_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str());
|
||||||
|
s.syncing = false;
|
||||||
|
}
|
||||||
|
gtk_widget_set_can_target(s.revealer, TRUE);
|
||||||
|
gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), TRUE);
|
||||||
|
sync_badge_bar(app);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void slideover_close(App& app) {
|
||||||
|
SlideOver& s = app.slideover;
|
||||||
|
if (s.revealer == nullptr || s.page.empty()) return;
|
||||||
|
s.page.clear();
|
||||||
|
gtk_widget_set_can_target(s.revealer, FALSE);
|
||||||
|
gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), FALSE);
|
||||||
|
sync_badge_bar(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool slideover_toggle(App& app, const std::string& id) {
|
||||||
|
if (!slideover_has_page(app, id)) return false;
|
||||||
|
if (app.slideover.page == id) {
|
||||||
|
slideover_close(app);
|
||||||
|
} else {
|
||||||
|
slideover_open(app, id);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::string& slideover_page(const App& app) { return app.slideover.page; }
|
||||||
|
|
||||||
|
bool slideover_escape(App& app) {
|
||||||
|
if (app.slideover.page.empty()) return false;
|
||||||
|
slideover_close(app);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool slideover_click_away(App& app, double x, double y) {
|
||||||
|
SlideOver& s = app.slideover;
|
||||||
|
if (s.page.empty() || s.overlay == nullptr || s.revealer == nullptr) return false;
|
||||||
|
graphene_point_t in = GRAPHENE_POINT_INIT(static_cast<float>(x), static_cast<float>(y));
|
||||||
|
graphene_point_t out;
|
||||||
|
if (gtk_widget_compute_point(s.overlay, s.revealer, &in, &out) &&
|
||||||
|
gtk_widget_contains(s.revealer, out.x, out.y)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
slideover_close(app);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the probe ---------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
struct ProbeState {
|
||||||
|
App* app;
|
||||||
|
int plot_w_before = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
bool under(GtkWidget* w, GtkWidget* ancestor) {
|
||||||
|
return w != nullptr && ancestor != nullptr && (w == ancestor || gtk_widget_is_ancestor(w, ancestor));
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean slideover_probe_read(gpointer user) {
|
||||||
|
ProbeState* ps = static_cast<ProbeState*>(user);
|
||||||
|
App& a = *ps->app;
|
||||||
|
SlideOver& s = a.slideover;
|
||||||
|
const int ow = gtk_widget_get_width(s.overlay);
|
||||||
|
const int oh = gtk_widget_get_height(s.overlay);
|
||||||
|
graphene_rect_t r;
|
||||||
|
const bool have = gtk_widget_compute_bounds(s.revealer, s.overlay, &r);
|
||||||
|
const int rw = have ? static_cast<int>(r.size.width + 0.5f) : -1;
|
||||||
|
const int rx = have ? static_cast<int>(r.origin.x + 0.5f) : -1;
|
||||||
|
const int rh = have ? static_cast<int>(r.size.height + 0.5f) : -1;
|
||||||
|
// Outside the panel, a quarter in: the plot must be what answers. Inside it, the panel.
|
||||||
|
GtkWidget* outside = gtk_widget_pick(s.overlay, ow / 4.0, oh / 2.0, GTK_PICK_DEFAULT);
|
||||||
|
GtkWidget* inside = gtk_widget_pick(s.overlay, ow - kSlideOverWidth / 2.0, oh / 2.0, GTK_PICK_DEFAULT);
|
||||||
|
const int plot_w_open = gtk_widget_get_width(a.plot_box);
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[slideover] open page=%s panel=%dx%d at x=%d overlay=%dx%d edge_flush=%d full_height=%d "
|
||||||
|
"outside_hits_plot=%d inside_hits_panel=%d plot_w before=%d open=%d plot_unchanged=%d\n",
|
||||||
|
s.page.c_str(), rw, rh, rx, ow, oh, rx + rw == ow ? 1 : 0, rh == oh ? 1 : 0,
|
||||||
|
under(outside, a.plot_box) ? 1 : 0, under(inside, s.revealer) ? 1 : 0, ps->plot_w_before,
|
||||||
|
plot_w_open, ps->plot_w_before == plot_w_open ? 1 : 0);
|
||||||
|
// Click-away from outside the panel closes; a press inside does not.
|
||||||
|
const bool inside_closed = slideover_click_away(a, ow - kSlideOverWidth / 2.0, oh / 2.0);
|
||||||
|
const bool open_after_inside = !s.page.empty();
|
||||||
|
const bool outside_closed = slideover_click_away(a, ow / 4.0, oh / 2.0);
|
||||||
|
slideover_open(a, "probe");
|
||||||
|
const bool esc_closed = slideover_escape(a);
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[slideover] click_inside_closes=%d still_open=%d click_outside_closes=%d esc_closes=%d "
|
||||||
|
"closed=%d\n",
|
||||||
|
inside_closed ? 1 : 0, open_after_inside ? 1 : 0, outside_closed ? 1 : 0, esc_closed ? 1 : 0,
|
||||||
|
s.page.empty() ? 1 : 0);
|
||||||
|
delete ps;
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
gboolean slideover_probe(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->slideover.stack == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
// A page of the probe's own: the real pages arrive in later tasks, and this one has an entry so the
|
||||||
|
// panel has something that takes focus and keys.
|
||||||
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||||||
|
gtk_box_append(GTK_BOX(box), gtk_label_new("slide-over probe page"));
|
||||||
|
gtk_box_append(GTK_BOX(box), gtk_entry_new());
|
||||||
|
slideover_add_page(*a, "probe", "Probe", box);
|
||||||
|
ProbeState* ps = new ProbeState{a, gtk_widget_get_width(a->plot_box)};
|
||||||
|
slideover_open(*a, "probe");
|
||||||
|
// The revealer's slide is 120 ms; the reading is taken well after it has settled.
|
||||||
|
g_timeout_add(500, slideover_probe_read, ps);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ui_impl
|
||||||
|
} // namespace udpscope
|
||||||
|
```
|
||||||
|
`sync_badge_bar(App&)` does not exist until task 2: in this task add to `ui_app.hpp` a declaration and, in `ui_slideover.cpp`'s neighbour, nothing -- instead **temporarily omit** the two `sync_badge_bar(app);` calls and add them in task 2 step 4 (they are marked `// task 2` if you prefer a comment). Check every GTK/libadwaita signature against the installed headers (`/usr/include/gtk-4.0`, `/usr/include/libadwaita-1`); fix names rather than guessing.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Declare, wire, register**
|
||||||
|
|
||||||
|
- `ui_app.hpp`: add the `SlideOver` struct, `kSlideOverWidth`, the function declarations from **Interfaces**, and on `App`: `SlideOver slideover; GtkWidget* plot_overlay = nullptr;`.
|
||||||
|
- `ui.cpp` `on_activate`: next to the other `gtk_builder_get_object` lookups add `app->plot_overlay = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_overlay"));`, add it to the null guard, and call `build_slideover(*app);` right after `app->plot_split` is resolved and the tour overlay is built (the plot overlay exists from the builder, so any point before `make_area` works).
|
||||||
|
- `ui.cpp` probe scheduling (next to line ~5906 `side_tab_probe`): `if (const char* env = std::getenv("UDPSCOPE_PROBE_SLIDEOVER_MS")) g_timeout_add(static_cast<guint>(std::atoi(env)), slideover_probe, &app);`
|
||||||
|
- `CMakeLists.txt`: add `src/ui_slideover.cpp` after `src/ui_source.cpp`.
|
||||||
|
- `docs/claude/verification.md`: add an entry for `UDPSCOPE_PROBE_SLIDEOVER_MS=2000` (with `UDPSCOPE_EXIT_AFTER_MS=5000`): the two `[slideover]` lines and what each field must read (below).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Build and run the probe**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build -j 2>&1 | grep -E 'warning|error' ; echo "build rc=$?"
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SLIDEOVER_MS=1500 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | grep -E '\[slideover\]|Gtk-|gtk warnings'
|
||||||
|
kill $MOCK
|
||||||
|
```
|
||||||
|
Expected: the build has zero warnings (incremental) and `[slideover]` line 1 reads `panel=360x<overlay height> ... edge_flush=1 full_height=1 outside_hits_plot=1 inside_hits_panel=1 ... plot_unchanged=1`; line 2 reads `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`; `gtk warnings: 0`. If `outside_hits_plot=0` the panel has an allocation wider than 360 or a transparent parent -- stop and fix, do not go on.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Confirm the compositing by eye (the risk the spec names)**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a -s "-screen 0 1400x900x24" sh -c 'GDK_BACKEND=x11 GSK_RENDERER=cairo UDPSCOPE_EXIT_AFTER_MS=7000 \
|
||||||
|
UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SLIDEOVER_MS=1500 ./build/udpscope --server 127.0.0.1 --port 8114 \
|
||||||
|
>/tmp/so.log 2>&1 & sleep 2.4; import -window root /tmp/slideover.png; wait'
|
||||||
|
kill $MOCK
|
||||||
|
```
|
||||||
|
The probe closes the page at ~2.0 s, so for the screenshot only: temporarily leave it open by setting `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which makes `slideover_probe_read` return before the click/esc part (add that early `if (std::getenv("UDPSCOPE_PROBE_SLIDEOVER_HOLD")) { delete ps; return G_SOURCE_REMOVE; }` after the first `fprintf`). Read `/tmp/slideover.png` with the Read tool and confirm: the panel is drawn **over** the traces on the plot's right edge, opaque, with its header, "Close" button, entry; the plot to its left is intact. Record the result in the task report. If the GL surface draws over the panel or the panel is transparent, stop: the design's premise failed, report BLOCKED.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Baseline diff and commit**
|
||||||
|
|
||||||
|
Re-run Step 1's command into `/tmp/ui-badges-t1.log`, normalise, `diff` against `.superpowers/sdd/ui-badges-baseline-udps.txt`.
|
||||||
|
Allowed baseline changes: none (a layout line that prints the plot area's size may differ by the panel not being open -- it must not).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui_slideover.cpp src/ui/main-window.ui src/ui_app.hpp src/ui.cpp src/ui_widgets.cpp CMakeLists.txt docs/claude/verification.md
|
||||||
|
git commit -m "ui: a slide-over panel over the plot's right edge, with its probe"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: BadgeBar with the Trigger badge, and the trigger form into the slide-over
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ui_badges.cpp`
|
||||||
|
- Modify: `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_widgets.cpp`, `src/ui_slideover.cpp`, `src/ui_probe.cpp`, `CMakeLists.txt`, `docs/claude/verification.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1's `slideover_add_page`, `slideover_toggle`, `slideover_page`, `slideover_open`.
|
||||||
|
- Produces:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// One badge: a button with a name and a state line. `page` is the slide-over page it opens.
|
||||||
|
struct Badge {
|
||||||
|
GtkWidget* button = nullptr;
|
||||||
|
GtkWidget* name = nullptr;
|
||||||
|
GtkWidget* state = nullptr;
|
||||||
|
std::string page;
|
||||||
|
};
|
||||||
|
struct BadgeBar {
|
||||||
|
GtkWidget* bar = nullptr; ///< main-window.ui `badge_bar`, fixed height
|
||||||
|
GtkWidget* signals_slot = nullptr; ///< left; empty in step 1, step 2 fills it
|
||||||
|
GtkWidget* system_slot = nullptr; ///< right: Trigger, Source
|
||||||
|
Badge trigger;
|
||||||
|
Badge source; ///< task 3
|
||||||
|
};
|
||||||
|
/// The Trigger badge's state word. Pure, so the probe can assert the whole table.
|
||||||
|
const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event);
|
||||||
|
void build_badge_bar(App& app);
|
||||||
|
void sync_badge_bar(App& app); // per tick and on every slide-over open/close; only writes what changed
|
||||||
|
gboolean badges_probe(gpointer user); // UDPSCOPE_PROBE_BADGES_MS
|
||||||
|
```
|
||||||
|
`App` gains `BadgeBar badges;` and, via the builder, `badge_bar`. `trigger_badge_state` truth table (enabled, armed, pending, has_event) -> word: `!enabled` -> `off`; `has_event && !pending` -> `triggered`; `pending` -> `capturing`; `armed` -> `armed`; else `disarmed`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the badge bar row to `main-window.ui`**
|
||||||
|
|
||||||
|
In `left_column`, between `plot_overlay` and `status_row`:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<child>
|
||||||
|
<!-- Setup is reached by the object it configures: a badge per signal on the left
|
||||||
|
(step 2), the system badges (Trigger, Source) on the right. A fixed height, so
|
||||||
|
the plot above it never moves when badges come and go. -->
|
||||||
|
<object class="GtkBox" id="badge_bar">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<property name="height-request">36</property>
|
||||||
|
<property name="margin-start">6</property>
|
||||||
|
<property name="margin-end">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="badge_signals">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkBox" id="badge_system">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<property name="halign">end</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
```
|
||||||
|
Add the three ids to the guard in `on_activate`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: CSS (`install_css`)**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// The badge bar: a badge is a pill carrying a name and a state; the one whose page is open is
|
||||||
|
// marked, as a bench scope marks the open menu's button.
|
||||||
|
".udpscope-badge { padding: 2px 12px; min-height: 0; border-radius: 14px; }\n"
|
||||||
|
".udpscope-badge-open { background-color: alpha(@accent_bg_color, 0.28); }\n"
|
||||||
|
".udpscope-badge-state { opacity: 0.7; }\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `src/ui_badges.cpp`**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
// The badge bar under the plot: setup is reached by the object it configures.
|
||||||
|
|
||||||
|
#include "ui_app.hpp"
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace ui_impl {
|
||||||
|
|
||||||
|
const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event) {
|
||||||
|
if (!enabled) return "off";
|
||||||
|
if (has_event && !pending) return "triggered";
|
||||||
|
if (pending) return "capturing";
|
||||||
|
return armed ? "armed" : "disarmed";
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
void set_text_if_changed(GtkWidget* label, const char* text) {
|
||||||
|
if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text);
|
||||||
|
}
|
||||||
|
|
||||||
|
void set_open(GtkWidget* button, bool open) {
|
||||||
|
if ((gtk_widget_has_css_class(button, "udpscope-badge-open") != FALSE) == open) return;
|
||||||
|
if (open) {
|
||||||
|
gtk_widget_add_css_class(button, "udpscope-badge-open");
|
||||||
|
} else {
|
||||||
|
gtk_widget_remove_css_class(button, "udpscope-badge-open");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_badge_clicked(GtkButton*, gpointer user) {
|
||||||
|
Badge* b = static_cast<Badge*>(user);
|
||||||
|
// The App is reached through the badge's button: the badges live inside App, so the pointer is stable.
|
||||||
|
App* app = static_cast<App*>(g_object_get_data(G_OBJECT(b->button), "udpscope-app"));
|
||||||
|
if (app != nullptr) slideover_toggle(*app, b->page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A badge: a flat pill, name on the left, state dimmed beside it. Words only.
|
||||||
|
void make_badge(App& app, Badge& b, GtkWidget* parent, const char* name, const std::string& page) {
|
||||||
|
b.page = page;
|
||||||
|
b.button = gtk_button_new();
|
||||||
|
gtk_widget_add_css_class(b.button, "flat");
|
||||||
|
gtk_widget_add_css_class(b.button, "udpscope-badge");
|
||||||
|
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
||||||
|
b.name = gtk_label_new(name);
|
||||||
|
b.state = gtk_label_new("");
|
||||||
|
gtk_widget_add_css_class(b.state, "udpscope-badge-state");
|
||||||
|
gtk_box_append(GTK_BOX(row), b.name);
|
||||||
|
gtk_box_append(GTK_BOX(row), b.state);
|
||||||
|
gtk_button_set_child(GTK_BUTTON(b.button), row);
|
||||||
|
g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app);
|
||||||
|
g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b);
|
||||||
|
gtk_box_append(GTK_BOX(parent), b.button);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void build_badge_bar(App& app) {
|
||||||
|
BadgeBar& bb = app.badges;
|
||||||
|
bb.bar = app.badge_bar;
|
||||||
|
bb.signals_slot = app.badge_signals;
|
||||||
|
bb.system_slot = app.badge_system;
|
||||||
|
make_badge(app, bb.trigger, bb.system_slot, "Trigger", "trigger");
|
||||||
|
sync_badge_bar(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
void sync_badge_bar(App& app) {
|
||||||
|
BadgeBar& bb = app.badges;
|
||||||
|
if (bb.trigger.button == nullptr) return;
|
||||||
|
const bool enabled = app.trigger_toggle != nullptr &&
|
||||||
|
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle)) != FALSE;
|
||||||
|
const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed);
|
||||||
|
const bool pending = app.model->trigger_pending();
|
||||||
|
const bool has_event = app.model->event() != nullptr;
|
||||||
|
set_text_if_changed(bb.trigger.state, trigger_badge_state(enabled, armed, pending, has_event));
|
||||||
|
set_open(bb.trigger.button, slideover_page(app) == bb.trigger.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean badges_probe(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
// The whole table of the pure function: 16 inputs, 5 words.
|
||||||
|
struct Row { bool e, a, p, v; const char* want; };
|
||||||
|
static const Row kRows[] = {
|
||||||
|
{false, false, false, false, "off"}, {false, true, true, true, "off"},
|
||||||
|
{true, false, false, true, "triggered"}, {true, true, false, true, "triggered"},
|
||||||
|
{true, true, true, false, "capturing"}, {true, false, true, true, "capturing"},
|
||||||
|
{true, true, false, false, "armed"}, {true, false, false, false, "disarmed"},
|
||||||
|
};
|
||||||
|
int bad = 0;
|
||||||
|
for (const Row& r : kRows) {
|
||||||
|
if (std::strcmp(trigger_badge_state(r.e, r.a, r.p, r.v), r.want) != 0) ++bad;
|
||||||
|
}
|
||||||
|
// The geometry: fixed height whatever the state, and the open mark follows the slide-over.
|
||||||
|
const int h_closed = gtk_widget_get_height(a->badges.bar);
|
||||||
|
slideover_toggle(*a, "trigger");
|
||||||
|
const bool marked_open = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") != FALSE;
|
||||||
|
const bool page_open = slideover_page(*a) == "trigger";
|
||||||
|
slideover_toggle(*a, "trigger");
|
||||||
|
const bool marked_closed = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") == FALSE;
|
||||||
|
const int h_after = gtk_widget_get_height(a->badges.bar);
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[badges] table_bad=%d trigger_state=%s bar_h=%d bar_h_after=%d fixed=%d open_marked=%d "
|
||||||
|
"page_open=%d closed_unmarked=%d\n",
|
||||||
|
bad, gtk_label_get_text(GTK_LABEL(a->badges.trigger.state)), h_closed, h_after,
|
||||||
|
h_closed == h_after && h_closed >= 36 ? 1 : 0, marked_open ? 1 : 0, page_open ? 1 : 0,
|
||||||
|
marked_closed ? 1 : 0);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ui_impl
|
||||||
|
} // namespace udpscope
|
||||||
|
```
|
||||||
|
(The probe checks `trigger_badge_state` against the full table the declaration gives. Add the 8 remaining combinations if you want all 16; the 8 above cover every word and every branch.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Wire it**
|
||||||
|
|
||||||
|
- `ui_app.hpp`: `Badge`, `BadgeBar`, declarations; on `App`: `BadgeBar badges; GtkWidget* badge_bar = nullptr; GtkWidget* badge_signals = nullptr; GtkWidget* badge_system = nullptr;`.
|
||||||
|
- `ui_slideover.cpp`: restore the two `sync_badge_bar(app);` calls from task 1 (end of `slideover_open` and `slideover_close`).
|
||||||
|
- `ui.cpp` `on_activate`: resolve the three ids, then `build_badge_bar(*app);` **after** `app->trigger_toggle` exists (it is created in `build_toolbar`, which runs before) and after `build_slideover`.
|
||||||
|
- `ui.cpp` `tick()`: call `sync_badge_bar(*app);` next to `sync_source_panels(*app);`.
|
||||||
|
- `ui.cpp`: probe scheduling `UDPSCOPE_PROBE_BADGES_MS` -> `badges_probe`.
|
||||||
|
- `CMakeLists.txt`: add `src/ui_badges.cpp`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Move the trigger form into the slide-over's `trigger` page**
|
||||||
|
|
||||||
|
In `on_activate` (currently ~ui.cpp:5187):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// Signals: the trigger picks a channel from the list beside it.
|
||||||
|
app->trigger_panel = build_trigger_panel(*app);
|
||||||
|
gtk_box_append(GTK_BOX(signals_page), app->trigger_panel);
|
||||||
|
gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
||||||
|
app->signal_panel = build_signal_panel(*app);
|
||||||
|
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
|
||||||
|
```
|
||||||
|
becomes
|
||||||
|
```cpp
|
||||||
|
// The trigger form lives in the slide-over (reached by the Trigger badge); the signal list stays in the
|
||||||
|
// sidebar until the dock replaces it.
|
||||||
|
app->trigger_panel = build_trigger_panel(*app);
|
||||||
|
slideover_add_page(*app, "trigger", "Trigger", app->trigger_panel);
|
||||||
|
app->signal_panel = build_signal_panel(*app);
|
||||||
|
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
|
||||||
|
```
|
||||||
|
Then deal with everything that assumed the trigger was on the Signals page **and mapped**: `grep -n "trigger_panel\|trigger_body\|trigger_head\|trigger_summary\|dbg_trigger_\|dbg_signal_page_current" src/*.cpp`. Specifically `exit_probe_step3` (`ui_probe.cpp` ~100-206) reads `gtk_widget_get_width/height(a->trigger_panel)`, `on_signals && gtk_widget_get_sensitive(a->trigger_body)`; any probe that reads a trigger widget's *allocation* must call `slideover_open(*a, "trigger")` and wait one settled frame (the reveal is 120 ms) before reading, and close it after. Reads of sensitivity/visibility/value need no open page. `dbg_signal_page_current` / `dbg_trigger_sensitive` keep their meaning but `on_signals` is now about the sidebar tab only for the signal list: `dbg_trigger_sensitive = gtk_widget_get_sensitive(a->trigger_body)`.
|
||||||
|
The trigger form's header toggle mirroring (`trigger_switch` <-> `trigger_toggle`), `sync_trigger_rows` and the locked summary view are all widget-state, not page-state; they must be unaffected -- the existing trigger probes are the proof (Step 6).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify**
|
||||||
|
|
||||||
|
Build (0 warnings). Then, serially, with a mock each:
|
||||||
|
1. `[badges]` probe: `UDPSCOPE_PROBE_BADGES_MS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500` against the UDPS mock -> `table_bad=0 fixed=1 open_marked=1 page_open=1 closed_unmarked=1`, `gtk warnings: 0`.
|
||||||
|
2. Every existing trigger probe in `docs/claude/verification.md` (grep `UDPSCOPE_PROBE_.*TRIGGER\|FORCE\|ARM\|PERSIST\|DENSITY\|CONDITION\|LOCK\|SUMMARY`; run those whose names mention the trigger panel, arming, locking, the summary or the panel switch) and compare against the reading each entry in `docs/claude/probe-readings.md` states. A probe that now reads zero because its widget is unallocated is the fix described in Step 5, not a regression to hide.
|
||||||
|
3. Screenshot with `UDPSCOPE_PROBE_BADGES_HOLD`-style early exit is not needed: add `slideover_open(*a,"trigger")` to nothing; instead take one screenshot (Task 1 step 7's command with `UDPSCOPE_PROBE_BADGES_MS` replaced by a one-off env `UDPSCOPE_OPEN_PAGE=trigger` read in `on_activate`'s tail -- remove it after) and view it: badge bar under the plot, Trigger badge pressed, the trigger form in the panel. Do not commit the temporary env.
|
||||||
|
4. Baseline diff (normalised) vs `.superpowers/sdd/ui-badges-baseline-udps.txt`.
|
||||||
|
Allowed baseline changes: the `side`/`trigger` size lines (`trigger_panel` is read through the slide-over), plus the new `[badges]` lines.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui_badges.cpp src/ui/main-window.ui src/ui_app.hpp src/ui.cpp src/ui_widgets.cpp src/ui_slideover.cpp src/ui_probe.cpp CMakeLists.txt docs/claude/verification.md
|
||||||
|
git commit -m "ui: a badge bar with a Trigger badge, and the trigger form in the slide-over"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: The Source badge and the source panels in the slide-over
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_source.cpp`, `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_probe.cpp`, `docs/claude/verification.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `slideover_add_page`, `slideover.source_pages`, `Badge`/`BadgeBar::source`.
|
||||||
|
- Produces: `build_source_panels(App&)` now registers `source-<id>` slide-over pages (id and `SourcePanelPage::name` unchanged); `sync_badge_bar` also writes the Source badge. `SourcePanelPage` is unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 1: `build_source_panels` registers slide-over pages**
|
||||||
|
|
||||||
|
In `src/ui_source.cpp`, replace the scroller + `adw_view_stack_add_titled` block with:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const std::string name = "source-" + p.id;
|
||||||
|
// The panel is a slide-over page now: the slide-over wraps it in its own scroller, and with two or
|
||||||
|
// more source pages it draws the segmented switch (the E-1608: Analog out, Digital I/O).
|
||||||
|
if (!slideover_add_page(app, name, p.title, p.widget)) continue;
|
||||||
|
app.source_panels.push_back({name, p.title, p.widget});
|
||||||
|
```
|
||||||
|
and change the guard at the top of the function from `app.side_stack == nullptr` to `app.slideover.stack == nullptr`. `apply_source_panels`/`sync_source_panels` (sensitivity from the connection flag) are unchanged.
|
||||||
|
Leave `keep_tabs_icon_free` in place for now (the sidebar tabs still exist until task 4); it must not be called with source pages any more -- it only hides images, so it is harmless.
|
||||||
|
|
||||||
|
- [ ] **Step 2: The Source badge**
|
||||||
|
|
||||||
|
In `build_badge_bar`, after the Trigger badge:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// The first source page is where the badge goes; a source with none (UDPS) has nothing to open, so its
|
||||||
|
// badge is a read-out: not a target, not focusable, but still on screen.
|
||||||
|
const std::string first = app.source_panels.empty() ? std::string() : app.source_panels.front().name;
|
||||||
|
make_badge(app, bb.source, bb.system_slot, "Source", first);
|
||||||
|
if (first.empty()) {
|
||||||
|
gtk_widget_set_can_target(bb.source.button, FALSE);
|
||||||
|
gtk_widget_set_focusable(bb.source.button, FALSE);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`build_badge_bar` must therefore run **after** `build_source_panels(*app)` -- move its call there. (Order in `on_activate`: `build_slideover`, trigger page, `build_source_panels`, `build_badge_bar`.) For a source's open mark, the Source badge is "open" for any `source-*` page: in `sync_badge_bar`, `set_open(bb.source.button, slideover_page(app).rfind("source-", 0) == 0)`.
|
||||||
|
State text (written in `sync_badge_bar`): the name label is the source kind's `display_name()` (`find_kind(app.source_kind)`, include `source/registry.hpp`; fall back to `"Source"`), the state is `connected` / `disconnected` from `app.model->stats().connected.load(std::memory_order_relaxed)`; in file mode (`app.replaying` or `!app.model->config().open_waveform.empty()`) the name is `"File"` and the state is empty. Set the name label with `set_text_if_changed` too.
|
||||||
|
The badge click on a multi-page source opens the **first** page; the segmented switch inside the panel changes page (Task 1's `on_source_switch`). If the open page is already a source page, a click closes it (that is `slideover_toggle`'s same-page rule, which the badge's `page` = first page gives for the first page; for any other open source page make `on_badge_clicked` close the panel when `b == &app->badges.source` and the open page starts with `source-`).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-point the probes that read the source pages**
|
||||||
|
|
||||||
|
- `ui_probe.cpp` ~1958 and ~2400: `adw_view_stack_get_child_by_name(side_stack, name) != nullptr` -> `slideover_has_page(*a, name)`.
|
||||||
|
- `ui_probe.cpp` ~2495: `adw_view_stack_set_visible_child_name(side_stack, "source-ao")` -> `slideover_open(*a, "source-ao")`; the probe reads panel widgets' values and sensitivity, and any read of an allocation needs the page open and one settled frame (>= 150 ms) -- keep the page open for the probe's reads and close it at the end.
|
||||||
|
- Add to the dump (or a `UDPSCOPE_PROBE_BADGES_MS` extension) a `[badges] source name=<...> state=<...> pages=<n> switch_visible=<0|1>` line: after `slideover_open(*a, <first source page>)`, `switch_visible` must read `1` for the E-1608 (two pages) and `0` for the probe source (one page); then select the second page via `adw_toggle_group_set_active_name` and read back `slideover_page(*a)`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify (serial; kill each mock)**
|
||||||
|
|
||||||
|
1. Probe source: `UDPSCOPE_PROBE_SOURCE_KINDS=3000 UDPSCOPE_EXIT_AFTER_MS=7000 UDPSCOPE_PROBE_BADGES_MS=2500 ./build/udpscope --source probe --source-opt rate=500` -> `[badges] source name=... pages=1 switch_visible=0`; `[source-kinds]` lines unchanged.
|
||||||
|
2. E-1608: `python3 tools/mock_e1608.py` (default port 54211), run the app with `--source e1608` the way `docs/claude/verification.md` states, with the E-1608 panel probes (`UDPSCOPE_PROBE_E1608_*`) and `UDPSCOPE_PROBE_BADGES_MS` -> `pages=2 switch_visible=1`, the panel probes' readings unchanged, `gtk warnings: 0`.
|
||||||
|
3. UDPS mock baseline diff. Allowed baseline changes: none beyond Task 2's; the Source badge on UDPS is the not-a-target read-out.
|
||||||
|
4. Screenshot of the E-1608 run with the Source page open (cairo, as Task 1 step 7), view it: the segmented switch under the header, the Analog out page below it.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui_source.cpp src/ui_badges.cpp src/ui_app.hpp src/ui.cpp src/ui_probe.cpp docs/claude/verification.md
|
||||||
|
git commit -m "ui: the Source badge, and the source panels as slide-over pages"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Replace the sidebar tabs with the temporary dock; session and keyboard
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui/main-window.ui`, `src/ui.cpp`, `src/ui_source.cpp`, `src/ui_app.hpp`, `src/ui_layout.cpp`, `src/layout_store.hpp`, `src/layout_store.cpp`, `src/shortcuts.hpp`, `src/shortcuts.cpp`, `src/ui/shortcuts.ui` (if it lists the two actions), `src/ui_probe.cpp`, `docs/claude/*` entries touched by their probes
|
||||||
|
- Read first: `docs/claude/invariants-session-and-export.md`, `docs/claude/invariants-plot-and-layout.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `App::dock_page` (the dock's `GtkBox`), `void dock_show_section(App&, const char* section)` (`"signals"` or `"measure"`; scrolls the dock to that section; unknown name is a no-op), `AppAction::ShowMeasure` / `AppAction::ShowSignals` (renamed from `SideTabMeasure` / `SideTabSignals`; same keys `m` and `g`, labels "Go to Measure" / "Go to Signals"), `SessionState::dock_shown` (bool, default true). **Removed:** `App::side_stack`, `App::side_switcher`, `App::side_tab`, `select_side_tab`, `on_side_tab_changed`, `keep_tabs_icon_free`, `SessionState::side_tab`, `App::dbg_switcher_*`, `dbg_signal_page_current`'s tab meaning.
|
||||||
|
|
||||||
|
- [ ] **Step 1: The dock in `main-window.ui`**
|
||||||
|
|
||||||
|
Replace the `side_column` subtree (switcher + stack + the two page scrollers) with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- The dock: one scroller holding the signal list, then the cursors and the measurements.
|
||||||
|
It replaces the tab switcher (four text tabs did not fit 320 px and a source can add
|
||||||
|
more); it is the temporary home of Measure and Signals until steps 2 and 3 turn them
|
||||||
|
into the badge bar's signals and the compact readouts. -->
|
||||||
|
<object class="GtkBox" id="side_column">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
<property name="width-request">300</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkScrolledWindow" id="dock_scroller">
|
||||||
|
<property name="vexpand">1</property>
|
||||||
|
<property name="hscrollbar-policy">never</property>
|
||||||
|
<property name="vscrollbar-policy">automatic</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkBox" id="dock_page">
|
||||||
|
<property name="orientation">vertical</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
```
|
||||||
|
Update the file's header comment (the sidebar is the dock; the panels appended to `dock_page`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fill the dock in `on_activate` and delete the tab code**
|
||||||
|
|
||||||
|
- Resolve `dock_page`, `dock_scroller` instead of `side_stack`, `side_switcher`, `measure_page`, `signals_page`; guard them; `app->side_box = side;` stays (the tour and the layout probes measure it). Add `GtkWidget* dock_page; GtkWidget* dock_scroller;` to `App`.
|
||||||
|
- Order in the dock, top to bottom: `signal_panel`, separator, `cursors_panel`, separator, `build_measures_panel(*app)`. The signal list is first because it is what is dragged onto the plot.
|
||||||
|
- Delete: the `adw_view_stack_set_visible_child_name(side_stack, "signals")` block and its `notify::visible-child-name` connection, `keep_tabs_icon_free(*app)` and its definition/declaration in `ui_source.cpp`/`ui_app.hpp` (with `hide_tab_icons`, `on_tab_pages_changed`), `on_side_tab_changed`, `select_side_tab` (`ui.cpp` ~2867-2882), `App::side_stack/side_switcher/side_tab`, and the `dbg_switcher_*` members.
|
||||||
|
- Add in `ui.cpp`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void dock_show_section(App& app, const char* section) {
|
||||||
|
if (app.dock_scroller == nullptr || app.dock_page == nullptr || section == nullptr) return;
|
||||||
|
GtkWidget* target = std::strcmp(section, "measure") == 0 ? app.cursors_panel
|
||||||
|
: std::strcmp(section, "signals") == 0 ? app.signal_panel
|
||||||
|
: nullptr;
|
||||||
|
if (target == nullptr) return;
|
||||||
|
graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f);
|
||||||
|
graphene_point_t at;
|
||||||
|
if (!gtk_widget_compute_point(target, app.dock_page, &zero, &at)) return;
|
||||||
|
GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(app.dock_scroller));
|
||||||
|
gtk_adjustment_set_value(adj, at.y);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `run_app_action`: `case AppAction::ShowMeasure: dock_show_section(app, "measure"); break;` / `ShowSignals` likewise. Rename in `shortcuts.hpp`, `shortcuts.cpp` (labels "Go to Measure" / "Go to Signals", same group "Panes and editors"), `shortcuts.ui` if present, and the keyboard probe (`ui_probe.cpp` ~6278-6305: it compared `side_tab` before/after; compare the dock scroller's `vadjustment` value instead -- `m` must move it to the cursors panel's y, `g` back to 0).
|
||||||
|
- Add `case Escape`: in `on_app_key_pressed`, directly after the tour's Escape branch and before the editable check: `if (keyval == GDK_KEY_Escape && slideover_escape(*app)) return TRUE;`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Dock visibility -- shown, insensitive until signals**
|
||||||
|
|
||||||
|
`update_side_visibility` (`ui.cpp` ~2887) and the per-frame block in `tick()` (~4029-4047) both compute `want = have_signals && sidebar_wanted`. Change both to `want = sidebar_wanted`, and state the dock's availability instead:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// "Disable, do not hide": the dock is on screen from the start and does nothing until the stream has signals.
|
||||||
|
gtk_widget_set_sensitive(app.side_box, have_signals);
|
||||||
|
gtk_widget_set_sensitive(app.sidebar_toggle, TRUE);
|
||||||
|
```
|
||||||
|
(`trigger_toggle`'s visibility from `have_signals` is unchanged; `update_side_visibility` keeps the early-return guard on `app.side_box == nullptr` instead of `side_stack`.) Update the comments above both blocks.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Session -- `side_tab` out, `dock_shown` in**
|
||||||
|
|
||||||
|
- `layout_store.hpp`: remove `side_tab`; add `bool dock_shown = true;` documented as "the dock's shown state (the user's last word on the toolbar's Dock button)".
|
||||||
|
- `layout_store.cpp`: writer (~743) emits `dock = true|false` in the same top section where `side_tab` was written (keep key order per the session invariants); parser: replace the `key == "side_tab"` branch with `key == "dock"` -> `st.dock_shown = value == "true"`; an old file's `side_tab = ...` falls through to the existing unknown-key handling (check the final `else` of that chain ignores it -- read it; do not add a branch to swallow it if it already does). Bump nothing else.
|
||||||
|
- `ui_layout.cpp:67` -> `st.dock_shown = app.sidebar_wanted;`; `:430` -> `app.sidebar_wanted = st.dock_shown;` then `update_side_visibility(app);`.
|
||||||
|
- A change of the user's wish must schedule a save: in `on_sidebar_toggled`, after recording `sidebar_wanted` when `!sidebar_syncing`, `if (!app->updating_panels) schedule_layout_save(*app);`.
|
||||||
|
- If there are session round-trip probes (`grep -n "side_tab" src/ui_probe.cpp docs/claude/*.md`), extend them: save with the dock hidden, reload, read `sidebar_wanted == false`; and a file containing `side_tab = "measure"` loads with no error and `dock_shown` default.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-point the layout/sidebar probes**
|
||||||
|
|
||||||
|
`ui_probe.cpp`: `exit_probe_step3` (~100-206), `side_tab_probe*` (~1800-1875), `sidebar_probe` (~3532-3567), the `select_side_tab(*a, "measure")` calls at ~4519 and ~4678, and the tour-surface probes (done in Task 5). Rules:
|
||||||
|
- `dbg_switcher_*` and the `switcher:` dump line (ui.cpp ~2998, 3055-3090, and the printing at ~6224-6436) are deleted; replace with one `dock: shown=<0|1> sensitive=<0|1> w=<px> scroller_pages=1` line and document it.
|
||||||
|
- `side_tab_probe` keeps its job (measurements keep accumulating while the Measure section is scrolled out of view) with `dock_show_section(*a, "measure")` / `"signals"` in place of `select_side_tab`; rename it `dock_section_probe`, env `UDPSCOPE_PROBE_SIDE_TAB_MS` is kept as is (do not break scripts); its `advanced=4` assertion is unchanged.
|
||||||
|
- `dbg_signal_page_current` becomes "the signal list has rows" (no tab), `dbg_trigger_sensitive` is read directly.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify**
|
||||||
|
|
||||||
|
Build (0 warnings incremental; clean build has exactly one). Serially: UDPS mock baseline diff; `UDPSCOPE_PROBE_SIDE_TAB_MS=3000 UDPSCOPE_EXIT_AFTER_MS=11000` (`advanced=4`); the sidebar/collapse probe; the keyboard probe; the session round-trip probe; the pane-drag probe (`dbg_drag_hid_sidebar`/`restored` both 1: dragging a signal from an overlaid dock still hides it for the drag); one run with **no signals** (UDPS mock with `--source` that has none, or a run before CONFIG) reading `dock: shown=1 sensitive=0`; E-1608 and probe-source runs from Task 3 again. Screenshot at 1280x800: plot, badge bar, dock with the list, cursors, measurements.
|
||||||
|
Allowed baseline changes: the `switcher:` line goes; a `dock:` line appears; `tab`/`page` readings that named `signals`/`measure` become the section readings; `[sidebar]` lines may differ only by `shown` when no signals existed.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -A src docs/claude
|
||||||
|
git commit -m "ui: the sidebar's tabs become one dock; Measure and Signals by keyboard section; session keeps the dock's state"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Tour, contextual help and the help figures
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `src/ui_app.hpp`, `src/ui_probe.cpp`; read `tools/check_help.sh`, `tools/make-help-shots.sh`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `TourView::on_page` -- `void (*on_page)(void* user, const char* page) = nullptr; void* page_user = nullptr;`. When set it replaces the `page_stack` mechanism for that view: `select_view_page` calls `on_page(page_user, stop->page)` (page may be `nullptr`, meaning "no page needed"). The chooser keeps `page_stack`. `TourStep::page` for the main window now names a slide-over page (`"trigger"`) or is `nullptr`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: The page hook in the shared tour machinery (`ui_tour.cpp` / `ui_app.hpp`)**
|
||||||
|
|
||||||
|
- Add the two members to `TourView`.
|
||||||
|
- `select_view_page`: first line `if (view.on_page != nullptr) { view.on_page(view.page_user, page); return; }`.
|
||||||
|
- `stop_is_walkable`: `if (stop.page != nullptr && view.on_page != nullptr) return true;` before the `page_stack` rule -- a slide-over page can always be opened, whatever the dock is doing.
|
||||||
|
- `start_tour`: replace `app.tour.page_stack = app.side_stack;` with `app.tour.on_page = +[](void* u, const char* page) { App& a = *static_cast<App*>(u); if (page != nullptr) slideover_open(a, page); else slideover_close(a); }; app.tour.page_user = &app;`. A stop with `page == nullptr` closes the panel, so the card never walks to the status line over an open panel. On `tour_end`, close the panel too (find where the tour tears down and call `slideover_close` through the same hook with `nullptr`).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Rewrite the main window's stops**
|
||||||
|
|
||||||
|
`kTourSteps` becomes (titles/bodies in plain words; keep the existing style):
|
||||||
|
|
||||||
|
| id | page | target | text (replace with your own wording of this meaning) |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `plot` | nullptr | `&App::area_overlay` | unchanged |
|
||||||
|
| `badges` | nullptr | `&App::badge_bar` | "Setup is reached by the thing it configures. The Trigger badge opens the trigger form; the Source badge opens the source's own controls. Click it again, press Esc or click the plot to close the panel." |
|
||||||
|
| `trigger` | `"trigger"` | `&App::trigger_body` | existing trigger text (describe an event, arm it) |
|
||||||
|
| `side` | nullptr | `&App::side_box` | "The dock beside the plot: the list of signals, then the cursors and the measurements. The buttons in the bar above show and hide it." |
|
||||||
|
| `signals` | nullptr | `&App::signal_list` | unchanged |
|
||||||
|
| `status` | nullptr | `&App::status_row` | unchanged |
|
||||||
|
|
||||||
|
Add `GtkWidget* App::badge_bar` to the table (it is already an `App` member from Task 2). Order matters for the walk: the trigger stop opens the panel, the `side` stop closes it. Update `kFigureSurfaces` (`ui_tour.cpp` ~484): drop `side_switcher` and `side_stack`; add `{"badge_bar", &App::badge_bar}` and `{"dock", &App::side_box}`. If `tools/check_help.sh`, `tools/make-help-shots.sh` or any `docs/help/**` figure data names `side_switcher`/`side_stack`, grep for them (`grep -rn "side_switcher\|side_stack" tools docs src data 2>/dev/null`): keep `side_stack` as a **second name for the dock** in `kFigureSurfaces` until the manual's figures are redone in step 4 (record that in the ledger), and drop `side_switcher` only if nothing names it.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Contextual help (`ui_context_help.cpp`)**
|
||||||
|
|
||||||
|
The hint table (~line 92 `{"signals", "The signals", ...}`) anchors "(i)" marks to panel hosts. Read the whole table. Hints whose host is now in the slide-over (the trigger's) are on an unmapped widget while the panel is closed: confirm the code that places them tolerates an unmapped host (hints are overlay children of the host; they should appear with the panel) and that `context_help_count` / the `hints=N ids={...}` reading is the same set of ids as before. Add a hint for the badge bar only if the table's pattern is "one hint per panel"; otherwise do not.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Re-point the tour probes**
|
||||||
|
|
||||||
|
`ui_probe.cpp` ~6565-6875 (`[tour]`, `[toursurface]`): `side_tab=` becomes `slideover=<page|none>` printed from `slideover_page(a)`; `select_side_tab(*a, "measure")` + `mapped(a->side_stack)` become `slideover_open(*a, "trigger")` and `mapped(a->side_box)`; the "collapsed" leg (dock collapsed) must show that the trigger stop is still walkable (it is the new behaviour: the slide-over does not depend on the dock) and the signals stop is dropped. Read `docs/claude/probe-readings.md` for the `[toursurface]` entry and update it in Task 6.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify**
|
||||||
|
|
||||||
|
Tour probes (`grep -n "TOUR\|HELPUI\|CONTEXT" docs/claude/verification.md` for their env names) -> the walk lists the stops above in order, `slideover=trigger` at the trigger stop and `slideover=none` at the next, spotlight rectangles contained in the overlay, `hints=` ids as before; `tools/check_help.sh` passes. UDPS baseline diff; allowed changes: tour step count and stop ids/titles, `side_tab=` fields.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui_tour.cpp src/ui_context_help.cpp src/ui_app.hpp src/ui_probe.cpp
|
||||||
|
git commit -m "ui: the tour and the contextual help follow the badge bar, the slide-over and the dock"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Documentation for step 1, and the final check
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`, `docs/claude/architecture.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/invariants-gtk-and-chrome.md`, `docs/claude/build-and-run.md` (only if it names a changed file), `CLAUDE.md` (only its table if a row's text is now wrong)
|
||||||
|
- Not in this plan (spec step 4): `USER-GUIDE.md`, the Mallard manual's text and figures.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Sweep the references**
|
||||||
|
|
||||||
|
`grep -rn "side_switcher\|side_stack\|side_tab\|switcher:\|keep_tabs_icon_free\|SideTab\|Measure tab\|Signals tab\|tab switcher\|sidebar tab" README.md CLAUDE.md docs/claude src tools` -- every hit outside `docs/superpowers/` is rewritten or deleted. Specifically:
|
||||||
|
- `README.md`: the layout description (badge bar, slide-over, dock), the keyboard table (`m`/`g` are "Go to Measure / Signals"), the session file's keys, the deviations list (the three step-1 deviations above, as known limitations of the in-progress redesign).
|
||||||
|
- `docs/claude/architecture.md`: the three new units and the rule that the slide-over/badge bar are built in `on_activate`, per-tick state in `sync_badge_bar`.
|
||||||
|
- `docs/claude/invariants-plot-and-layout.md`: the slide-over as the second thing placed over the plot (after the layout editor's buttons), with the pick rule; the badge bar's fixed height.
|
||||||
|
- `docs/claude/invariants-gtk-and-chrome.md`: delete the tab-icon paragraph and `keep_tabs_icon_free` (the switcher is gone); add: a capture-phase observer gesture that never claims the sequence is how click-away coexists with the plot's gestures; `AdwToggleGroup` is the segmented switch.
|
||||||
|
- `docs/claude/probe-readings.md` and `verification.md`: the new lines `[slideover]`, `[badges]`, `dock:`, the removal of `switcher:`; the new baseline statement for the UDPS diff ("unchanged except ..." now relative to the commit before this series, with the allowed list); the renamed keyboard actions.
|
||||||
|
- `docs/claude/verification.md` header sentence about the UDPS baseline: say a self-check taken **before** `c572ec2` is no longer the baseline for these lines.
|
||||||
|
|
||||||
|
- [ ] **Step 2: The final run, serially**
|
||||||
|
|
||||||
|
Clean build count: `touch src/icons/udpscope.gresource.xml && cmake --build build -j 2>&1 | grep -c warning` -> `1`; incremental -> `0`. UDPS mock full self-check: `gtk warnings: 0`, no `Gtk-CRITICAL`, `buffer allocated:` lines 2-3. Each probe touched in Tasks 1-5 once more, on the final tree. A last screenshot at 1280x800 with the trigger page open and one with a source page open (E-1608 mock) for the report. Kill every mock.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md CLAUDE.md docs/claude
|
||||||
|
git commit -m "docs: the badge bar, the slide-over and the dock (step 1 of the redesign)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review (against the spec)
|
||||||
|
|
||||||
|
- **Spec step 1 -- "plot overlay, badge bar with Trigger and Source, slide-over holding the existing trigger form and source pages, sidebar tabs removed, existing Measure and Signals in a temporary dock":** Task 1 (overlay + slide-over), Task 2 (badge bar, Trigger, trigger form), Task 3 (Source, source pages), Task 4 (tabs removed, temporary dock).
|
||||||
|
- **Risks the spec assigns to step 1:** overlay above GL -> Task 1 steps 6-7 before anything else; reparenting the trigger form -> Task 2 step 6 (existing trigger probes). "Many signals" is step 2.
|
||||||
|
- **Header:** the Events toggle removal and the Dock toggle rename are *not* step 1 (the events pane stays until step 4); the sidebar toggle already is the dock toggle in behaviour -- its tooltip/label text, if it says "sidebar", is renamed "Dock" in Task 4 step 3 (look at `header-bar.ui`).
|
||||||
|
- **Keyboard / session / probes / tour / docs:** Tasks 4, 4, 4-5, 5, 6.
|
||||||
|
- **Left to later steps (by the spec's rollout):** signal badges and drag (2), compact readouts and the 280 px dock (3), events section and deleting the left pane (4), USER-GUIDE and manual figures (4).
|
||||||
|
- **Names used across tasks:** `slideover_add_page/has_page/open/close/toggle/page/escape/click_away`, `SlideOver::source_pages`, `build_badge_bar`, `sync_badge_bar`, `trigger_badge_state`, `BadgeBar::{trigger,source}`, `dock_show_section`, `ShowMeasure/ShowSignals`, `SessionState::dock_shown`, `TourView::on_page/page_user` are used with one spelling throughout.
|
||||||
@@ -0,0 +1,836 @@
|
|||||||
|
# Horizontal plot split Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Let each row of the stacked plots be split into 1..4 plots side by side, all sharing one time window, with draggable cell widths saved in the session.
|
||||||
|
|
||||||
|
**Architecture:** Extend the vendored plotter's `plot::Figure` so a figure row holds several plots (flattened plot index, `FigureLayout.cells`). Adapters hit-test by cell rect. The host `RowSet` keeps its flattened plot index (`row_count()` = plot count) and gains `cells_per_row_` plus per-row cell weights. The layout editor, session file, signals page and a new headless probe follow.
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4/libadwaita, vendored `deps/plotter` (own `PLOT_TEST` suite, GLFW + GTK4 adapters), CMake, headless probes under `xvfb-run` against `tools/mock_udpstreamer.py`.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-11-03-horizontal-split-design.md`
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- All plots (cells in a row, rows in the figure) are members of the one x link group. No new time semantics; a split is not an independent time window and not an XY plot.
|
||||||
|
- Each figure row holds 1..4 cells, independently of the other rows. At most 4 cells per row and at most 8 plots in total (`RowSet::kMaxRows` keeps its value and now bounds plots, not figure rows).
|
||||||
|
- `Figure(u32 rows)` stays and means one cell per row; existing behaviour and `tests/test_figure.cpp` are unchanged.
|
||||||
|
- `plot(i)` indexes the flattened cells, top to bottom, left to right. Everything keyed by a plot index in the host keeps meaning "flattened plot index".
|
||||||
|
- Widths use the height scheme: a floor per cell, the weights share the rest, the last cell closes the row exactly (no seam). A figure too narrow for the floors shrinks the cells proportionally and drops the column gap; no rect is ever negative.
|
||||||
|
- Only the bottom figure row's cells carry x labels and the x title; other cells get `XStrip::None`.
|
||||||
|
- The left y gutter stays one common maximum for the whole figure with the same reserved floor (layout must not depend on content).
|
||||||
|
- `FigureLayout` gains `cells` (one `Rect` per plot, flattened). `rows` stays the full-width rect per figure row.
|
||||||
|
- Cursors: the vertical pair is one shared time drawn in every cell; horizontal rulers are per cell. The crosshair is one x on every cell; the hovered cell shows its y. Y axes are per cell. An x gesture anywhere moves the shared window.
|
||||||
|
- Layout editor: cell `+` insensitive at 4 cells in the row or 8 plots in total; row `+` insensitive at 8 plots in total; each cell keeps its top-left `×` (removes the cell in a multi-cell row, the row in a one-cell row; insensitive when it would leave no plot). Divider drag floors each neighbour at about 96 px logical and is saved on drag end.
|
||||||
|
- Session file: `[[plot]]` tables gain `row` (the figure row) and `width` (the cell's weight). A session without `row` loads as one cell per row. Row `weight` stays per figure row (carried on the row's first cell).
|
||||||
|
- Every source file carries an SPDX MIT header; match it in new files.
|
||||||
|
- No unit-test suite for the app: extend the headless probes in `src/ui.cpp` / `src/ui_probe.cpp`. The plotter has its own `PLOT_TEST` suite.
|
||||||
|
- Baselines for every run: exactly one build warning on a clean build; `gtk warnings: 0`; no `Gtk-CRITICAL`.
|
||||||
|
- Keep verification runs short and serial; kill the mock servers you start (never the foreign mock on port 8114).
|
||||||
|
- Plotter changes are committed inside `deps/plotter` (not pushed), then the new pin is committed in the main repo with `git add deps/plotter`.
|
||||||
|
- Branch is `feature/source-plugins`. Do not push or merge to `main`.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `deps/plotter/include/plot/plot/figure.hpp`, `src/plot/figure.cpp` (paths as found in the submodule) | `Figure` cell grid, `place_cells`, `FigureLayout.cells` |
|
||||||
|
| `deps/plotter/src/.../renderer.cpp` | draw every plot (`plot_count()`) |
|
||||||
|
| `deps/plotter/tests/test_figure.cpp`, `tests/test_glfw_host.cpp` | plotter tests |
|
||||||
|
| `deps/plotter/adapters/gtk4/gtk_plot_area.*`, `adapters/glfw/glfw_host.*` | x-aware pointer routing over cell rects |
|
||||||
|
| `src/plot_host.hpp/.cpp` | `RowSet` cell state and operations |
|
||||||
|
| `src/layout_store.hpp/.cpp` | `row`/`width` in the session file |
|
||||||
|
| `src/ui_layout.cpp` | `collect_layout` / `apply_layout` |
|
||||||
|
| `src/ui.cpp`, `src/ui_app.hpp` | layout editor controls, notices, probe scheduling |
|
||||||
|
| `src/ui_signal_page.cpp`, `src/ui_signals.cpp` | "Plot on" labels, drop target notices |
|
||||||
|
| `src/ui_probe.cpp` | `UDPSCOPE_PROBE_SPLIT_MS` probe, layout probe re-baseline |
|
||||||
|
| docs (see Task 8) | user and developer docs |
|
||||||
|
|
||||||
|
Locate the exact plotter paths first with `git -C deps/plotter ls-files | grep -E 'figure|renderer|gtk_plot_area|glfw_host'`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Plotter `Figure` cell grid
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `deps/plotter/.../figure.hpp`, `deps/plotter/.../figure.cpp`, `deps/plotter/.../renderer.cpp` (~line 474)
|
||||||
|
- Test: `deps/plotter/tests/test_figure.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (all in `plot::Figure`):
|
||||||
|
- `explicit Figure(std::span<const u32> cells_per_row);` (a count of 0 is clamped to 1). `explicit Figure(u32 rows)` stays and means one cell per row. No `initializer_list` constructor (`Figure{3u}` must keep its meaning).
|
||||||
|
- `u32 row_count() const` = figure rows; `u32 plot_count() const`; `u32 cells_in_row(u32 row) const`; `u32 first_plot_of_row(u32 row) const` (returns `plot_count()` for `row == row_count()`); `u32 row_of_plot(u32 plot) const`.
|
||||||
|
- `Status set_cell_weights(u32 row, std::span<const f32> weights)`; `void clear_cell_weights(u32 row)`; `bool has_cell_weights(u32 row) const`; `void set_col_gap(f32 px)`; `f32 col_gap() const`.
|
||||||
|
- `FigureLayout::cells` (`std::vector<Rect>`, flattened, one per plot) and `FigureLayout::col_gap` (`f32`, 0 when any row dropped the gap).
|
||||||
|
- `Plot& plot(u32 i)` indexes flattened cells; `set_row_weights` validates against the figure row count.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read the current code**
|
||||||
|
|
||||||
|
Read `figure.hpp` fully and `figure.cpp` `layout()`, the constructor, `set_row_weights`, `set_crosshair`. Note member names `plots_`, `row_weights_`, `row_gap_`, `min_y_gutter_`, `right_gutter_`, `labels_bottom_only_`, `links_`, and `kMinRowHeightLogical = 24`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write the failing tests**
|
||||||
|
|
||||||
|
Append to `tests/test_figure.cpp` (add `#include <span>` and `#include <vector>` if missing). Use the file's existing helpers `figure_with_axes`, `give_magnitude_row`, `whole_figure()` if present; if the viewport helper has a different name, use the one the neighbouring tests use for a 640x480 viewport.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
plot::Figure split_figure(const std::vector<plot::u32>& cells)
|
||||||
|
{
|
||||||
|
plot::Figure figure{std::span<const plot::u32>(cells)}; // braces: parentheses would be a most-vexing parse
|
||||||
|
figure.set_viewport(plot::Rect{0, 0, 640, 480});
|
||||||
|
for (plot::u32 i = 0; i < figure.plot_count(); ++i) {
|
||||||
|
figure.plot(i).add_x_axis();
|
||||||
|
figure.plot(i).add_y_axis();
|
||||||
|
}
|
||||||
|
return figure;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_all_ones_equals_rows)
|
||||||
|
{
|
||||||
|
auto split = split_figure({1, 1, 1});
|
||||||
|
auto plain = figure_with_axes(3);
|
||||||
|
split.layout();
|
||||||
|
plain.layout();
|
||||||
|
PLOT_CHECK(split.row_count() == 3u && split.plot_count() == 3u);
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) {
|
||||||
|
PLOT_CHECK_NEAR(split.layout_info().cells[i].y, plain.layout_info().rows[i].y, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(split.layout_info().cells[i].h, plain.layout_info().rows[i].h, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(split.layout_info().cells[i].w, plain.layout_info().rows[i].w, 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_tile_exactly)
|
||||||
|
{
|
||||||
|
auto figure = split_figure({1, 2, 1});
|
||||||
|
figure.set_col_gap(10.0f);
|
||||||
|
figure.layout();
|
||||||
|
const auto& fl = figure.layout_info();
|
||||||
|
PLOT_CHECK(figure.row_count() == 3u && figure.plot_count() == 4u);
|
||||||
|
PLOT_CHECK(figure.cells_in_row(1) == 2u);
|
||||||
|
PLOT_CHECK(figure.first_plot_of_row(1) == 1u && figure.first_plot_of_row(3) == 4u);
|
||||||
|
PLOT_CHECK(figure.row_of_plot(2) == 1u);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 10.0, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[1].right(), 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].y, fl.cells[2].y, 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].h, fl.cells[2].h, 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].w, fl.cells[2].w, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(fl.col_gap, 10.0, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cell_weights_and_floor)
|
||||||
|
{
|
||||||
|
auto figure = split_figure({2});
|
||||||
|
figure.set_col_gap(0.0f);
|
||||||
|
const std::vector<plot::f32> w{3.0f, 1.0f};
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0, w));
|
||||||
|
figure.layout();
|
||||||
|
{
|
||||||
|
const auto& c = figure.layout_info().cells;
|
||||||
|
PLOT_CHECK_NEAR((c[0].w - 64.0) / (c[1].w - 64.0), 3.0, 0.01);
|
||||||
|
}
|
||||||
|
const std::vector<plot::f32> tiny{1000.0f, 0.001f};
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0, tiny));
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.layout_info().cells[1].w >= 64.0f - 0.001f);
|
||||||
|
const std::vector<plot::f32> three{1.0f, 1.0f, 1.0f};
|
||||||
|
const std::vector<plot::f32> zero{1.0f, 0.0f};
|
||||||
|
PLOT_CHECK(!figure.set_cell_weights(0, three));
|
||||||
|
PLOT_CHECK(!figure.set_cell_weights(0, zero));
|
||||||
|
PLOT_CHECK(!figure.set_cell_weights(5, w));
|
||||||
|
figure.clear_cell_weights(0);
|
||||||
|
PLOT_CHECK(!figure.has_cell_weights(0));
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.layout_info().cells[0].w, figure.layout_info().cells[1].w, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_narrow_degrades)
|
||||||
|
{
|
||||||
|
std::vector<plot::u32> cells{4};
|
||||||
|
plot::Figure figure{std::span<const plot::u32>(cells)};
|
||||||
|
figure.set_viewport(plot::Rect{0, 0, 20, 100});
|
||||||
|
for (plot::u32 i = 0; i < 4; ++i) { figure.plot(i).add_x_axis(); figure.plot(i).add_y_axis(); }
|
||||||
|
figure.set_col_gap(10.0f);
|
||||||
|
figure.layout();
|
||||||
|
const auto& fl = figure.layout_info();
|
||||||
|
double sum = 0;
|
||||||
|
for (const auto& c : fl.cells) {
|
||||||
|
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
|
||||||
|
PLOT_CHECK_NEAR(c.h, 100.0, 0.01);
|
||||||
|
sum += c.w;
|
||||||
|
}
|
||||||
|
PLOT_CHECK_NEAR(sum, 20.0, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(fl.col_gap, 0.0, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_share_x_link_and_crosshair)
|
||||||
|
{
|
||||||
|
auto figure = split_figure({2, 1});
|
||||||
|
const auto group = figure.add_x_link();
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) figure.link_x(group, i, 0);
|
||||||
|
figure.plot(1).set_x_range(0, {3.0, 9.0});
|
||||||
|
figure.layout();
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) {
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).x_range(0).lo, 3.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).x_range(0).hi, 9.0, 1e-9);
|
||||||
|
}
|
||||||
|
figure.set_crosshair(4.0, true);
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) {
|
||||||
|
PLOT_CHECK(figure.plot(i).crosshair().active);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).crosshair().x, 4.0, 1e-9);
|
||||||
|
}
|
||||||
|
figure.clear_crosshair();
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) PLOT_CHECK(!figure.plot(i).crosshair().active);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_x_labels_bottom_row_only)
|
||||||
|
{
|
||||||
|
auto figure = split_figure({1, 2});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.plot(0).layout().x_label_area.h == 0.0f);
|
||||||
|
PLOT_CHECK(figure.plot(1).layout().x_label_area.h > 0.0f);
|
||||||
|
PLOT_CHECK(figure.plot(2).layout().x_label_area.h > 0.0f);
|
||||||
|
PLOT_CHECK_NEAR(figure.layout_info().x_axis_strip.h, figure.plot(2).layout().x_label_area.h, 0.001);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_cells_common_y_gutter)
|
||||||
|
{
|
||||||
|
auto figure = split_figure({2});
|
||||||
|
give_magnitude_row(figure.plot(0), 0);
|
||||||
|
give_magnitude_row(figure.plot(1), 1);
|
||||||
|
figure.layout();
|
||||||
|
const auto& fl = figure.layout_info();
|
||||||
|
const double g0 = figure.plot(0).layout().axes.x - fl.cells[0].x;
|
||||||
|
const double g1 = figure.plot(1).layout().axes.x - fl.cells[1].x;
|
||||||
|
PLOT_CHECK(g0 > 0.0);
|
||||||
|
PLOT_CHECK_NEAR(g0, g1, 0.01);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Adjust accessor names (`layout_info()`, `set_viewport`, `link_x`, `x_range`, `add_x_axis`) to the ones the existing tests in the same file use; the intent of each test is fixed.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cmake --build deps/plotter/build --target plot_tests 2>&1 | tail -20` (use the plotter build dir the repo already has; if none, configure with the plotter's own `CMakeLists.txt` and `-DPLOT_BUILD_TESTS=ON`).
|
||||||
|
Expected: compile failure, `plot_count` / `set_cell_weights` not members of `Figure`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Implement the header**
|
||||||
|
|
||||||
|
In `figure.hpp`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
explicit Figure(u32 rows);
|
||||||
|
explicit Figure(std::span<const u32> cells_per_row);
|
||||||
|
|
||||||
|
u32 row_count() const; // figure rows
|
||||||
|
u32 plot_count() const; // flattened cells
|
||||||
|
u32 cells_in_row(u32 row) const;
|
||||||
|
u32 first_plot_of_row(u32 row) const; // plot_count() for row == row_count()
|
||||||
|
u32 row_of_plot(u32 plot) const;
|
||||||
|
|
||||||
|
Status set_cell_weights(u32 row, std::span<const f32> weights);
|
||||||
|
void clear_cell_weights(u32 row);
|
||||||
|
bool has_cell_weights(u32 row) const;
|
||||||
|
void set_col_gap(f32 px);
|
||||||
|
f32 col_gap() const;
|
||||||
|
```
|
||||||
|
|
||||||
|
Members: `std::vector<u32> cells_per_row_; std::vector<u32> row_first_; // prefix sums, rows+1 entries; std::vector<std::vector<f32>> cell_weights_; f32 col_gap_ = 0.0f; void build_cells(std::span<const u32>);`. In `FigureLayout` add `std::vector<Rect> cells; f32 col_gap = 0.0f;`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Implement the constructors and accessors**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
Figure::Figure(u32 rows)
|
||||||
|
{
|
||||||
|
const std::vector<u32> ones(rows, 1u);
|
||||||
|
build_cells(std::span<const u32>(ones));
|
||||||
|
}
|
||||||
|
|
||||||
|
Figure::Figure(std::span<const u32> cells_per_row) { build_cells(cells_per_row); }
|
||||||
|
|
||||||
|
void Figure::build_cells(std::span<const u32> cells)
|
||||||
|
{
|
||||||
|
cells_per_row_.assign(cells.begin(), cells.end());
|
||||||
|
for (u32& c : cells_per_row_) c = std::max<u32>(c, 1u);
|
||||||
|
row_first_.assign(cells_per_row_.size() + 1, 0u);
|
||||||
|
for (std::size_t r = 0; r < cells_per_row_.size(); ++r)
|
||||||
|
row_first_[r + 1] = row_first_[r] + cells_per_row_[r];
|
||||||
|
plots_ = std::vector<Plot>(row_first_.back());
|
||||||
|
cell_weights_.assign(cells_per_row_.size(), {});
|
||||||
|
// keep whatever the old Figure(u32) constructor initialised besides plots_ (row_weights_ etc.)
|
||||||
|
}
|
||||||
|
|
||||||
|
u32 Figure::row_count() const { return static_cast<u32>(cells_per_row_.size()); }
|
||||||
|
u32 Figure::plot_count() const { return static_cast<u32>(plots_.size()); }
|
||||||
|
u32 Figure::cells_in_row(u32 row) const { return row < row_count() ? cells_per_row_[row] : 0u; }
|
||||||
|
u32 Figure::first_plot_of_row(u32 row) const { return row <= row_count() ? row_first_[row] : plot_count(); }
|
||||||
|
u32 Figure::row_of_plot(u32 plot) const
|
||||||
|
{
|
||||||
|
u32 r = 0;
|
||||||
|
while (r + 1 < row_count() && row_first_[r + 1] <= plot) ++r;
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status Figure::set_cell_weights(u32 row, std::span<const f32> w)
|
||||||
|
{
|
||||||
|
if (row >= row_count() || w.size() != cells_per_row_[row]) return Status::invalid_argument();
|
||||||
|
for (f32 v : w) if (!(v > 0.0f)) return Status::invalid_argument();
|
||||||
|
cell_weights_[row].assign(w.begin(), w.end());
|
||||||
|
return Status::ok();
|
||||||
|
}
|
||||||
|
void Figure::clear_cell_weights(u32 row) { if (row < row_count()) cell_weights_[row].clear(); }
|
||||||
|
bool Figure::has_cell_weights(u32 row) const { return row < row_count() && !cell_weights_[row].empty(); }
|
||||||
|
void Figure::set_col_gap(f32 px) { col_gap_ = std::max(0.0f, px); }
|
||||||
|
f32 Figure::col_gap() const { return col_gap_; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the `Status` constructors the file already uses for `set_row_weights` failures (copy that spelling). Remove any other `plots_` initialisation from the old constructor and make `set_row_weights` validate against `row_count()` (it already did if it used `row_count()`; verify it did not use `plots_.size()`).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Implement `place_cells` and wire it into `layout()`**
|
||||||
|
|
||||||
|
In an anonymous namespace of `figure.cpp`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
constexpr f32 kMinCellWidthLogical = 64.0f;
|
||||||
|
|
||||||
|
// Splits the row rect `r` into n cells. Returns the gap actually used.
|
||||||
|
f32 place_cells(const Rect& r, u32 n, const std::vector<f32>& weights, f32 col_gap, f32 ui_scale,
|
||||||
|
Rect* out)
|
||||||
|
{
|
||||||
|
if (n == 1) { out[0] = r; return col_gap; }
|
||||||
|
f32 gap = col_gap;
|
||||||
|
f32 usable = r.w - gap * static_cast<f32>(n - 1);
|
||||||
|
if (usable <= 0.0f) { gap = 0.0f; usable = r.w; }
|
||||||
|
usable = std::max(0.0f, usable);
|
||||||
|
const f32 floor_px = std::min(kMinCellWidthLogical * ui_scale, usable / static_cast<f32>(n));
|
||||||
|
const f32 extra = usable - floor_px * static_cast<f32>(n);
|
||||||
|
f32 total = 0.0f;
|
||||||
|
if (weights.size() == n) for (f32 w : weights) total += w;
|
||||||
|
f32 x = r.x;
|
||||||
|
f32 consumed = 0.0f;
|
||||||
|
for (u32 i = 0; i < n; ++i) {
|
||||||
|
const f32 share = (weights.size() == n && total > 0.0f) ? weights[i] / total
|
||||||
|
: 1.0f / static_cast<f32>(n);
|
||||||
|
const f32 end = (i + 1 == n) ? usable : consumed + floor_px + extra * share;
|
||||||
|
out[i] = Rect{x, r.y, end - consumed, r.h};
|
||||||
|
consumed = end;
|
||||||
|
x = out[i].right() + gap;
|
||||||
|
}
|
||||||
|
return gap;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `Figure::layout()`, after the row rects (`layout_.rows`) are computed and before viewports are set: size `layout_.cells` to `plot_count()`, call `place_cells` per figure row (pass `&layout_.cells[row_first_[r]]`), track `layout_.col_gap = (any row with n>1 and returned gap < col_gap_) ? 0 : col_gap_` (set `col_gap` to `col_gap_` otherwise), and set each plot's viewport to `layout_.cells[i]`. The strip rule becomes: per plot `i`, `XStrip::None` when `labels_bottom_only_` and `row_of_plot(i) + 1 != row_count()`, else the existing strip. Every loop that ran over rows for `set_right_gutter`, `sync_x_links()`, the two gutter passes and crosshair now runs over `plots_` (flattened). Keep `layout_.x_axis_strip = plots_.back().layout().x_label_area`. The ui_scale reference stays `plots_[0].ui_scale()`.
|
||||||
|
|
||||||
|
In `renderer.cpp` ~474 replace `const u32 rows = figure.row_count();` with `figure.plot_count()`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run tests**
|
||||||
|
|
||||||
|
Run: `cmake --build deps/plotter/build --target plot_tests && deps/plotter/build/plot_tests 2>&1 | tail -15`
|
||||||
|
Expected: all tests pass, including the pre-existing `figure_*` tests.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit in the submodule**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: rows can hold several plots (cells), cell weights and column gap"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Adapters route pointers by cell
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `deps/plotter/adapters/gtk4/gtk_plot_area.cpp/.hpp`, `deps/plotter/adapters/glfw/glfw_host.cpp/.hpp`
|
||||||
|
- Test: `deps/plotter/tests/test_glfw_host.cpp`
|
||||||
|
- Modify (main repo): `deps/plotter` pin
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `Figure::plot_count()`, `FigureLayout::cells`.
|
||||||
|
- Produces: `GtkPlotArea::row_at(x, y)` returns the flattened plot index under (x, y) (nearest cell when outside all); GLFW `Host::controller_at(x, y)` is x-aware. `row_controller(i)` indexes flattened plots.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing GLFW routing test**
|
||||||
|
|
||||||
|
Add `#include <span>` and, in `tests/test_glfw_host.cpp`, next to the existing `Routed(rows, gap)` fixture test:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
PLOT_TEST(glfw_host_routes_by_cell)
|
||||||
|
{
|
||||||
|
std::vector<plot::u32> cells{1, 2};
|
||||||
|
plot::Figure figure{std::span<const plot::u32>(cells)};
|
||||||
|
figure.set_viewport(plot::Rect{0, 0, 300, 600});
|
||||||
|
figure.set_col_gap(6.0f);
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) { figure.plot(i).add_x_axis(); figure.plot(i).add_y_axis(); }
|
||||||
|
figure.layout();
|
||||||
|
plot::glfw::Host host;
|
||||||
|
host.set_figure(&figure);
|
||||||
|
const auto& c = figure.layout_info().cells;
|
||||||
|
for (plot::u32 i = 0; i < 3; ++i) {
|
||||||
|
const double cx = c[i].x + c[i].w * 0.5, cy = c[i].y + c[i].h * 0.5;
|
||||||
|
PLOT_CHECK(&host.controller_at(cx, cy) == host.row_controller(i));
|
||||||
|
}
|
||||||
|
// in the gap: nearest cell wins
|
||||||
|
PLOT_CHECK(&host.controller_at(c[1].right() + 1.0, c[1].y + 5.0) == host.row_controller(1));
|
||||||
|
PLOT_CHECK(&host.controller_at(c[2].x - 1.0, c[2].y + 5.0) == host.row_controller(2));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Mirror the existing fixture for how `controller_at` / `row_controller` are called (names may differ slightly; keep the three assertions).
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `cmake --build deps/plotter/build --target test_glfw_host 2>&1 | tail` then run the binary.
|
||||||
|
Expected: FAIL (the cell-2 centre routes to cell 1, or build error).
|
||||||
|
|
||||||
|
- [ ] **Step 3: GLFW host**
|
||||||
|
|
||||||
|
In `glfw_host.cpp`: `set_figure` uses `plot_count()` for the controllers; `refresh_row_rects` copies `layout().cells`; replace `row_in(rows, y)` with
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
static int row_in(const std::vector<Rect>& rects, double x, double y)
|
||||||
|
{
|
||||||
|
int best = -1;
|
||||||
|
double best_d = 0.0;
|
||||||
|
for (std::size_t i = 0; i < rects.size(); ++i) {
|
||||||
|
const Rect& r = rects[i];
|
||||||
|
const double dx = x < r.x ? r.x - x : (x > r.right() ? x - r.right() : 0.0);
|
||||||
|
const double dy = y < r.y ? r.y - y : (y > r.bottom() ? y - r.bottom() : 0.0);
|
||||||
|
const double d = dx * dx + dy * dy;
|
||||||
|
if (best < 0 || d < best_d) { best = static_cast<int>(i); best_d = d; }
|
||||||
|
if (d == 0.0) break;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Use `r.y + r.h` / `r.x + r.w` if `Rect` has no `bottom()`.) For unsplit figures this is equivalent to the old y-only nearest-row rule. Make `controller_at(x, y)`, `pointer_target(x, y, ...)` and `pointer_row(rows)` pass `x` (use `last_pointer_x_` in `pointer_row`) and drop the `(void)x`. `sync_figure_crosshair` loops over the plot count. Update the header comments.
|
||||||
|
|
||||||
|
- [ ] **Step 4: GTK adapter**
|
||||||
|
|
||||||
|
In `gtk_plot_area.cpp`: `set_figure` uses `plot_count()` (line ~248); `refresh_row_rects` caches `.layout().cells`; add the same x-aware `row_in(rows, x, y)`; change `controller_at`, `row_at(x, y)` (use `to_device(x, y)`), `target_controller(f32 x, f32 y)`, `pointer_row()` (`pointer_.x/.y`) and `press_row_` (set with `row_in(row_rects_, p.x, p.y)` at ~619). Update every call site of `target_controller` (~637 `p`, ~646 `position`, ~751 `sample.position`, ~798 `g`, ~837 `at`, ~861 `p`) to pass both coordinates. Update the header declarations.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build and run plotter tests**
|
||||||
|
|
||||||
|
Run: `cmake --build deps/plotter/build 2>&1 | tail -5 && for t in deps/plotter/build/test_*; do $t >/dev/null || echo FAIL $t; done`
|
||||||
|
Expected: no FAIL lines.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit the submodule, then the pin; check the app still builds**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C deps/plotter add -u && git -C deps/plotter commit -m "adapters: route pointer events by cell rect"
|
||||||
|
git add deps/plotter && git commit -m "deps/plotter: figure rows can hold several plots"
|
||||||
|
cmake --build build 2>&1 | grep -c warning
|
||||||
|
```
|
||||||
|
Expected: the build succeeds with the single baseline warning (count as before the change; compare with `git stash`-free run of an untouched clean build if unsure).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `RowSet` cell state and the split probe scaffold
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/plot_host.hpp`, `src/plot_host.cpp` (`rebuild_all` ~309, `set_row_count` ~156, `carry_row` ~164, `clear_row` ~184, `insert_row` ~195, `remove_row` ~205, `apply_row_weights` ~303, `x_note` caption ~920)
|
||||||
|
- Modify: `src/ui_app.hpp` (`dbg_split_step`, declarations near ~2093), `src/ui_probe.cpp` (new `split_probe_*`), `src/ui.cpp` (scheduling next to `UDPSCOPE_PROBE_LAYOUT_MS`, ~6003)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces in `RowSet`:
|
||||||
|
- `static constexpr int kMaxCellsPerRow = 4;`
|
||||||
|
- `int figure_row_count() const; int cells_in_row(int fr) const; int first_plot_of_row(int fr) const; int figure_row_of(int plot) const /* -1 if invalid */; int cell_of(int plot) const; const std::vector<float>& cell_weights(int fr) const;`
|
||||||
|
- `std::string plot_label(int plot) const` ("2" for a single-cell row, "2a".."2d" for a split row; the figure row is `fr + 1`)
|
||||||
|
- `bool insert_cell(int fr, int at)`, `bool remove_cell(int plot)`, `void set_cell_weights(int fr, std::vector<float> w)`, `void set_cells_per_row(std::vector<int> cells)`
|
||||||
|
- `insert_row(at)` / `remove_row(at)` act on figure rows; `set_row_count(n)` means n single-cell rows; `row_count()` stays the plot count.
|
||||||
|
- Invariant: `sum(cells_per_row_) == rows_` (rows_ ≤ kMaxRows = 8).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read** `plot_host.hpp` 35–215, 280–320 and `plot_host.cpp` 135–345, 905–950 to see `carry_row`, `clear_row`, `storage_`, `rebuild_all`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Header additions**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
static constexpr int kMaxCellsPerRow = 4;
|
||||||
|
|
||||||
|
int figure_row_count() const { return static_cast<int>(cells_per_row_.size()); }
|
||||||
|
int cells_in_row(int fr) const;
|
||||||
|
int first_plot_of_row(int fr) const; // rows_ for fr == figure_row_count()
|
||||||
|
int figure_row_of(int plot) const; // -1 if plot is out of range
|
||||||
|
int cell_of(int plot) const; // 0-based position inside its row, -1 if invalid
|
||||||
|
const std::vector<float>& cell_weights(int fr) const;
|
||||||
|
std::string plot_label(int plot) const;
|
||||||
|
|
||||||
|
bool insert_cell(int fr, int at); // false at kMaxCellsPerRow cells or kMaxRows plots
|
||||||
|
bool remove_cell(int plot); // a single-cell row removes the row; false if it would leave no plot
|
||||||
|
void set_cell_weights(int fr, std::vector<float> w);
|
||||||
|
void set_cells_per_row(std::vector<int> cells);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::vector<int> cells_per_row_{1};
|
||||||
|
std::vector<std::vector<float>> cell_weights_ = std::vector<std::vector<float>>(1);
|
||||||
|
void apply_cell_weights();
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement queries and labels** in `plot_host.cpp`
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
int RowSet::cells_in_row(int fr) const
|
||||||
|
{
|
||||||
|
return fr >= 0 && fr < figure_row_count() ? cells_per_row_[fr] : 0;
|
||||||
|
}
|
||||||
|
int RowSet::first_plot_of_row(int fr) const
|
||||||
|
{
|
||||||
|
int p = 0;
|
||||||
|
for (int r = 0; r < fr && r < figure_row_count(); ++r) p += cells_per_row_[r];
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
int RowSet::figure_row_of(int plot) const
|
||||||
|
{
|
||||||
|
if (plot < 0 || plot >= rows_) return -1;
|
||||||
|
int p = 0;
|
||||||
|
for (int r = 0; r < figure_row_count(); ++r) {
|
||||||
|
p += cells_per_row_[r];
|
||||||
|
if (plot < p) return r;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
int RowSet::cell_of(int plot) const
|
||||||
|
{
|
||||||
|
const int fr = figure_row_of(plot);
|
||||||
|
return fr < 0 ? -1 : plot - first_plot_of_row(fr);
|
||||||
|
}
|
||||||
|
const std::vector<float>& RowSet::cell_weights(int fr) const
|
||||||
|
{
|
||||||
|
static const std::vector<float> none;
|
||||||
|
return fr >= 0 && fr < static_cast<int>(cell_weights_.size()) ? cell_weights_[fr] : none;
|
||||||
|
}
|
||||||
|
std::string RowSet::plot_label(int plot) const
|
||||||
|
{
|
||||||
|
const int fr = figure_row_of(plot);
|
||||||
|
if (fr < 0) return std::to_string(plot + 1);
|
||||||
|
if (cells_per_row_[fr] == 1) return std::to_string(fr + 1);
|
||||||
|
return std::to_string(fr + 1) + static_cast<char>('a' + cell_of(plot));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Figure construction in `rebuild_all`**
|
||||||
|
|
||||||
|
Replace the `std::make_unique<plot::Figure>(rows_)` with
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::vector<plot::u32> cells(cells_per_row_.begin(), cells_per_row_.end());
|
||||||
|
figure_ = std::make_unique<plot::Figure>(std::span<const plot::u32>(cells));
|
||||||
|
```
|
||||||
|
|
||||||
|
and after `set_row_gap(kRowGap)` add `figure_->set_col_gap(kColGap);` (`constexpr float kColGap = 6.0f;` beside `kRowGap`), then call `apply_row_weights(); apply_cell_weights();`. The per-plot loop stays per plot index (it already iterates `rows_`).
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void RowSet::apply_cell_weights()
|
||||||
|
{
|
||||||
|
if (!figure_) return;
|
||||||
|
cell_weights_.resize(cells_per_row_.size());
|
||||||
|
for (int fr = 0; fr < figure_row_count(); ++fr) {
|
||||||
|
auto& w = cell_weights_[fr];
|
||||||
|
if (cells_per_row_[fr] < 2) { w.clear(); continue; }
|
||||||
|
if (w.empty()) { figure_->clear_cell_weights(fr); continue; }
|
||||||
|
w.resize(cells_per_row_[fr], 1.0f);
|
||||||
|
figure_->set_cell_weights(fr, std::span<const float>(w));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`apply_row_weights` resizes to `figure_row_count()` instead of `rows_`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Mutators**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool RowSet::insert_cell(int fr, int at)
|
||||||
|
{
|
||||||
|
if (fr < 0 || fr >= figure_row_count()) return false;
|
||||||
|
if (cells_per_row_[fr] >= kMaxCellsPerRow || rows_ >= kMaxRows) return false;
|
||||||
|
const int n = cells_per_row_[fr];
|
||||||
|
const int p = first_plot_of_row(fr) + std::clamp(at, 0, n);
|
||||||
|
for (int i = rows_; i > p; --i) carry_row(i, i - 1); // carry_row(dst, src), as insert_row uses it
|
||||||
|
clear_row(p);
|
||||||
|
++rows_;
|
||||||
|
++cells_per_row_[fr];
|
||||||
|
auto& w = cell_weights_[fr];
|
||||||
|
if (!w.empty()) {
|
||||||
|
float mean = 0.0f;
|
||||||
|
for (float v : w) mean += v;
|
||||||
|
mean /= static_cast<float>(w.size());
|
||||||
|
w.insert(w.begin() + std::clamp(at, 0, n), mean);
|
||||||
|
}
|
||||||
|
rebuild_all();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool RowSet::remove_cell(int plot)
|
||||||
|
{
|
||||||
|
const int fr = figure_row_of(plot);
|
||||||
|
if (fr < 0) return false;
|
||||||
|
if (cells_per_row_[fr] == 1) {
|
||||||
|
if (figure_row_count() == 1) return false;
|
||||||
|
remove_row(fr);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (int i = plot; i + 1 < rows_; ++i) carry_row(i, i + 1);
|
||||||
|
--rows_;
|
||||||
|
clear_row(rows_);
|
||||||
|
const int k = plot - first_plot_of_row(fr);
|
||||||
|
--cells_per_row_[fr];
|
||||||
|
auto& w = cell_weights_[fr];
|
||||||
|
if (cells_per_row_[fr] < 2) w.clear();
|
||||||
|
else if (k < static_cast<int>(w.size())) w.erase(w.begin() + k);
|
||||||
|
rebuild_all();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::set_cell_weights(int fr, std::vector<float> w)
|
||||||
|
{
|
||||||
|
if (fr < 0 || fr >= figure_row_count()) return;
|
||||||
|
cell_weights_[fr] = std::move(w);
|
||||||
|
if (figure_) apply_cell_weights();
|
||||||
|
}
|
||||||
|
|
||||||
|
void RowSet::set_cells_per_row(std::vector<int> cells)
|
||||||
|
{
|
||||||
|
std::vector<int> next;
|
||||||
|
int total = 0;
|
||||||
|
for (int c : cells) {
|
||||||
|
c = std::clamp(c, 1, kMaxCellsPerRow);
|
||||||
|
if (total + c > kMaxRows) c = kMaxRows - total;
|
||||||
|
if (c < 1) break;
|
||||||
|
next.push_back(c);
|
||||||
|
total += c;
|
||||||
|
}
|
||||||
|
if (next.empty()) next.push_back(1), total = 1;
|
||||||
|
cells_per_row_ = std::move(next);
|
||||||
|
cell_weights_.assign(cells_per_row_.size(), {});
|
||||||
|
rows_ = total;
|
||||||
|
rebuild_all();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Check against the real `carry_row` signature (read in Step 1) and keep its argument order; if `insert_row` uses a different shifting idiom, copy it. `insert_row(at)` / `remove_row(at)`: `at` is a figure row; the plot position is `first_plot_of_row(at)`; insert/erase the matching `cells_per_row_` entry (one cell for an inserted row) and `cell_weights_` entry; `remove_row` shifts storage down by the removed row's cell count `n` and decrements `rows_` by `n`. `insert_row` refuses when `rows_ >= kMaxRows`. `set_row_count(n)`: sets all cells to one per row (`cells_per_row_.assign(n, 1)`, `cell_weights_.assign(n, {})`) and early-returns only when `n == rows_` and every row already has one cell.
|
||||||
|
|
||||||
|
- [ ] **Step 6: The `x_note` caption** (~line 920): replace `const bool bottom = i == rows_ - 1;` with `const bool bottom = i >= first_plot_of_row(figure_row_count() - 1);`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Probe scaffold**
|
||||||
|
|
||||||
|
In `ui_app.hpp` add `int dbg_split_step = 0;` beside `dbg_layout_step` and declarations beside the layout probe's: `void split_probe_report(App& app, const char* tag); void split_probe_next(App& app); gboolean split_probe_step(gpointer data);`. In `ui_probe.cpp` (SPDX header already in the file) add, modelled on `layout_probe_*` (read `ui_probe.cpp` 3386–3471 and 3751–3890 first, reuse `enable_all_probe`'s channel assignment loop):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void split_probe_report(App& app, const char* tag)
|
||||||
|
{
|
||||||
|
RowSet& rs = *app.rows;
|
||||||
|
const auto& fl = rs.figure().layout();
|
||||||
|
int tile = 1;
|
||||||
|
for (int fr = 0; fr < rs.figure_row_count(); ++fr) {
|
||||||
|
const int first = rs.first_plot_of_row(fr), n = rs.cells_in_row(fr);
|
||||||
|
const auto& row = fl.rows[fr];
|
||||||
|
if (std::fabs(fl.cells[first].x - row.x) > 0.5f) tile = 0;
|
||||||
|
if (std::fabs(fl.cells[first + n - 1].right() - row.right()) > 0.5f) tile = 0;
|
||||||
|
for (int k = 0; k < n; ++k) {
|
||||||
|
const auto& c = fl.cells[first + k];
|
||||||
|
if (std::fabs(c.y - row.y) > 0.5f || std::fabs(c.h - row.h) > 0.5f) tile = 0;
|
||||||
|
if (k > 0 && std::fabs((c.x - fl.cells[first + k - 1].right()) - fl.col_gap) > 0.5f) tile = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// x_equal: every plot's x range equals plot 0's; cursors_aligned: every plot's cursor a/b x equal
|
||||||
|
int x_equal = 1, aligned = 1;
|
||||||
|
const auto r0 = rs.plot(0)->x_range(rs.axis_x(0));
|
||||||
|
for (int p = 1; p < rs.row_count(); ++p) {
|
||||||
|
const auto r = rs.plot(p)->x_range(rs.axis_x(p));
|
||||||
|
const double span = std::max(1e-12, std::fabs(r0.hi - r0.lo));
|
||||||
|
if (std::fabs(r.lo - r0.lo) > 1e-9 * span || std::fabs(r.hi - r0.hi) > 1e-9 * span) x_equal = 0;
|
||||||
|
if (std::fabs(rs.cursor_x(p, 0) - rs.cursor_x(0, 0)) > 1e-9 * span) aligned = 0;
|
||||||
|
if (std::fabs(rs.cursor_x(p, 1) - rs.cursor_x(0, 1)) > 1e-9 * span) aligned = 0;
|
||||||
|
}
|
||||||
|
// print: [split-probe] %-14s plots=%d frows=%d cells=[a,b] widths=[logical px, | between rows] tile=%d x_equal=%d cursors_aligned=%d weights=[...] traces=[P:name,...|...]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The accessor names `plot(p)`, `axis_x(p)` and `cursor_x(p, which)` are placeholders for the host's real accessors for the plot object, its x axis index and cursor A/B x (the layout probe and `RowSet::cursor_a_x()` show them: `r.plot->cursor(r.cursor_a).x`, `r.plot->x_range(r.axis_x)`); use those, adding a tiny `RowSet::cursor_x(int plot, int which)` if none exists. Fill in the print line with `g_print`/`std::printf` in the style of `layout_probe_report`, with widths in logical px (`cell.w / scale`), weights from `rs.cell_weights(fr)` and traces as `P:name` from the slot names the layout probe already prints.
|
||||||
|
|
||||||
|
`split_probe_step` follows `layout_probe_step` (200 ms) but with 250 ms steps; after any structural change call `app.area->set_figure(&app.rows->figure()); app.relayout_pending = true;` and let a frame run before the report. Steps:
|
||||||
|
0. `rows->set_cells_per_row({1}); ` free the slots; enable all signals and assign channels via the `enable_all_probe` loop; report `"start"` (expect `plots=1 frows=1 cells=[1]`).
|
||||||
|
1. `insert_cell(0, 1)`; assign channel 0 to plot 0 and channel 1 to plot 1; `set_cursors_visible(true)`; report `"split"` (expect `cells=[2] tile=1 x_equal=1 cursors_aligned=1`).
|
||||||
|
2. `set_cell_weights(0, {3, 1})`; report `"weights 3:1"` (widths ratio about 3:1 after the floors; `tile=1`).
|
||||||
|
3. `insert_row(1)` then `insert_cell(1, 1)`; report `"row+cell"` (expect `cells=[2,2] plots=4`).
|
||||||
|
4. Call `insert_cell` until it returns false and then `insert_row` until the plot total is 8; report `"caps"` printing the refusals (`insert_cell(0, 2)` true, `…` false at 4 cells; `insert_row` false at 8 plots). Keep these as separate printed `refused=` fields.
|
||||||
|
5. `remove_cell` on the last plot of the widest row, report `"remove"`; then finish by returning `G_SOURCE_REMOVE`.
|
||||||
|
|
||||||
|
In `ui.cpp` next to `UDPSCOPE_PROBE_LAYOUT_MS` (~6003) add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (const char* ms = g_getenv("UDPSCOPE_PROBE_SPLIT_MS"))
|
||||||
|
g_timeout_add(static_cast<guint>(std::atoi(ms)), split_probe_step, app);
|
||||||
|
```
|
||||||
|
|
||||||
|
(Use the exact `g_timeout_add` argument shape the layout probe uses.)
|
||||||
|
|
||||||
|
- [ ] **Step 8: Build and run the probe**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cmake --build build 2>&1 | grep -E 'warning|error' | head
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8171 --rate 1000 --batch 20 & MOCK=$!; sleep 2
|
||||||
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_SPLIT_MS=1500 ./build/udpscope --server 127.0.0.1 --port 8171 2>&1 | grep -E 'split-probe|gtk warnings|Gtk-CRITICAL'
|
||||||
|
kill $MOCK
|
||||||
|
```
|
||||||
|
Expected: the six `[split-probe]` lines above with `tile=1 x_equal=1 cursors_aligned=1`, `gtk warnings: 0`, no `Gtk-CRITICAL`. Also confirm that the first build output still has exactly one warning.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/plot_host.hpp src/plot_host.cpp src/ui_app.hpp src/ui_probe.cpp src/ui.cpp
|
||||||
|
git commit -m "plot host: rows hold 1..4 cells; split probe scaffold"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Session file and probe round-trip
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/layout_store.hpp` (`LayoutState::Row`), `src/layout_store.cpp` (writer ~786, `plots = N` ~729, parser `[[plot]]` ~457 and keys ~605–614, fallback ~718), `src/ui_layout.cpp` (`collect_layout` 17–36, `apply_layout` ~445–520)
|
||||||
|
- Modify: `src/ui_probe.cpp` (new probe steps)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `RowSet::cells_in_row`, `figure_row_of`, `cell_weights`, `set_cells_per_row`, `set_cell_weights`, `set_row_weights`.
|
||||||
|
- Produces: `LayoutState::Row { …; int row = -1; double width = 1.0; }`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Format**
|
||||||
|
|
||||||
|
`LayoutState::Row` gains `int row = -1; // figure row; -1 = file had none` and `double width = 1.0; // cell weight`. The writer emits, per `[[plot]]`: `row = <fr>`, `width = <w>`, then the existing `weight` (the figure row's weight, written on every cell) and `signals`. Parser: keys `row` (int ≥ 0) and `width` (double > 0) next to `weight`. A file where no `[[plot]]` has `row` leaves every `row == -1`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `collect_layout`** — for each plot `p`: `fr = rs.figure_row_of(p)`, `row.row = fr`, `row.weight = row_weights()[fr]`, `row.width = cell_weights(fr)` entry `cell_of(p)` or 1.0 when the row has no weights.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `apply_layout`** — before `set_row_count`: if every `Row.row >= 0`, validate that rows are non-decreasing, contiguous from 0 (each step 0 or +1), at most `kMaxCellsPerRow` cells per row and at most `kMaxRows` plots, otherwise fall back to the one-cell-per-row path. Valid: build `cells_per_row` counts and call `rows->set_cells_per_row(counts)`; row weights come from the first cell of each figure row (`set_row_weights`); for rows with ≥ 2 cells call `set_cell_weights(fr, {widths…})`. Fallback (no `row`, or invalid): `set_row_count(min(n, kMaxRows))` as today. The per-plot signal assignment loop is unchanged (it is per flattened plot index).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Probe steps** — append to `split_probe_step`:
|
||||||
|
6. Build `{1,2}` with weights `{3,1}` and signals in each cell, `collect_layout` → `save_layout(tmp)` (use a path under `g_get_tmp_dir()` and delete it afterwards) → set to `{1}` → `load_layout` → `apply_layout`; report `"roundtrip"` and add `rt=1/0` comparing cells, widths (tolerance 1e-6) and traces.
|
||||||
|
7. Write a copy of that file with all `row = …` lines stripped, load/apply it, report `"legacy"` (expect `cells=[1,1,1]`, or the plot count of the file, one cell per row).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the probe** (same recipe as Task 3 Step 8). Expected: `roundtrip rt=1`, `legacy` with one cell per row, `gtk warnings: 0`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/layout_store.hpp src/layout_store.cpp src/ui_layout.cpp src/ui_probe.cpp
|
||||||
|
git commit -m "session: plots carry row and width; files without row load one cell per row"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Layout editor cell controls and column drag
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui.cpp` (layout editor 568–807), `src/ui_layout.cpp` (534–598 handlers), `src/ui_app.hpp` (constants ~1600–1607, new `App` fields), `src/ui_probe.cpp` (`layout_probe_*` re-baseline, new split-probe click steps)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 3 `RowSet` API, Task 2 `FigureLayout.cells`.
|
||||||
|
- Produces: `void layout_col_apply(App&, int plot, double offset_px)`, `GtkWidget* layout_cell_button(App&, int fr, int pos)`, `GtkWidget* layout_col_handle(App&, int plot)`; `App::layout_col_drag_plot`, `App::layout_col_drag_left0` / `layout_col_drag_right0` (start widths, logical px); constants `kLayoutCellKey = "udpscope-layout-cell"`, `kLayoutColKey = "udpscope-layout-col"`, `kMinCellPx = 96`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read** `ui.cpp` 568–807 (overlay build, `sync_layout_editor`, `layout_overlay_button`), `ui_layout.cpp` 534–598 and the drag handlers, `ui_app.hpp` ~1600–1607.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Row-keyed controls by figure row.** The row boundary `+` buttons and the row resize handles are keyed by **figure row** (`figure_row_count() + 1` boundaries, positioned from `fl.rows`). `on_layout_row_insert(boundary)` calls `rows->insert_row(boundary)`. The per-cell `×` stays keyed by plot index (existing `set_row_key`), positioned from `fl.cells[idx]` top-left, and its handler calls `rows->remove_cell(idx)`; it is insensitive when `remove_cell` would fail (`row_count() == 1`). After each handler: `app->area->set_figure(&app->rows->figure())`, `rebuild_layout_editor`, `rebuild_signal_panels`, `schedule_layout_save` (same tail as the existing handlers). The row `+` is insensitive at `row_count() >= RowSet::kMaxRows`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Cell `+` buttons.** For each figure row `fr` with `n = cells_in_row(fr)`, create `n + 1` buttons via `layout_overlay_button(app, "list-add-symbolic", tooltip)` with `g_object_set_data(btn, kLayoutCellKey, GINT_TO_POINTER(fr * 8 + pos + 1))` (do **not** call `set_row_key`). Positions: `pos 0` at `cells[first].x/scale + kLayoutButtonPad`; internal `pos k` centred on the gap between cells `k-1` and `k`; `pos n` at `cells[last].right()/scale - kLayoutButtonPx - kLayoutButtonPad`; vertically centred in `fl.rows[fr]`. Clicking calls `rows->insert_cell(fr, pos)` then the same tail as Step 2. Sensitivity: `cells_in_row(fr) < kMaxCellsPerRow && row_count() < kMaxRows`. Convert with `/scale` as the existing code does in `sync_layout_editor`, and re-place them in `sync_layout_editor` every frame.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Column resize handles.** At each internal boundary `k` (1..n-1) of a split row create a drag handle with `kLayoutColKey` = plot index of the right-hand cell (`first + k`), placed just right of the cell `+` there, with a left-right arrow icon (`go-next-symbolic` is acceptable, use whatever the row handle's icon pair provides for the horizontal direction), copying the row handle's `GtkGestureDrag` ("udpscope-layout-drag" pattern with its own begin/update/end handlers). Handlers:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void layout_col_apply(App& app, int plot, double offset_px)
|
||||||
|
{
|
||||||
|
RowSet& rs = *app.rows;
|
||||||
|
const int fr = rs.figure_row_of(plot);
|
||||||
|
const int k = rs.cell_of(plot);
|
||||||
|
if (fr < 0 || k < 1) return;
|
||||||
|
const double scale = gtk_widget_get_scale_factor(app.window) > 0 ? ui_scale_of(app) : 1.0; // use the helper sync_layout_editor uses
|
||||||
|
const double pair = app.layout_col_drag_left0 + app.layout_col_drag_right0;
|
||||||
|
const double want_left = std::clamp(app.layout_col_drag_left0 + offset_px / scale, double(kMinCellPx), pair - kMinCellPx);
|
||||||
|
std::vector<float> w = rs.cell_weights(fr);
|
||||||
|
w.resize(rs.cells_in_row(fr), 1.0f);
|
||||||
|
const float sum = w[k - 1] + w[k];
|
||||||
|
w[k - 1] = static_cast<float>(sum * want_left / pair);
|
||||||
|
w[k] = sum - w[k - 1];
|
||||||
|
rs.set_cell_weights(fr, w);
|
||||||
|
app.area->queue_draw(); // same redraw call the row drag uses
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`begin` records `layout_col_drag_plot = plot` and the logical widths `cells[plot-1].w/scale` and `cells[plot].w/scale`, and also snapshots `rs.cell_weights(fr)` (resized with 1.0) so repeated `update` calls are relative to drag start, as the row drag is. If the pair is narrower than `2 * kMinCellPx`, make the clamp degenerate to `pair / 2` rather than inverted bounds. `end` calls `schedule_layout_save(app)`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Tooltips and sensitivity wording.** Cell `+`: "Split here"; column handle: "Drag to resize the plots"; `×` in a multi-cell row: "Remove this plot"; one-cell row: unchanged ("Remove this row").
|
||||||
|
|
||||||
|
- [ ] **Step 6: Helpers for the probe.** `layout_cell_button(App&, fr, pos)` and `layout_col_handle(App&, plot)` find the widget by walking the overlay children and matching `kLayoutCellKey` / `kLayoutColKey` data (as `layout_button` does for `row_key`).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Probe steps.** Append to `split_probe_step`: with the layout editor open (reuse how `layout_probe_step` opens it), 8. click `layout_cell_button(app, 0, 1)` via `layout_click` → expect `cells=[2]`; 9. call `layout_col_apply` through the real handle's begin/update/end (or `layout_col_apply(app, 1, 80.0)` after setting the start widths as the begin handler does) → widths shift by 80 logical px and neither cell is below 96; a drag of ±10000 px clamps both to ≥ 96; 10. click the `×` of the new cell → `cells=[1]`; 11. report the sensitivity of the cell `+` at 4 cells in the row (`cell_plus_sensitive=0`) and of the row `+` at 8 plots.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Re-baseline the layout probe.** Run the existing `UDPSCOPE_PROBE_LAYOUT_MS` probe on the previous commit and on this one (fresh `XDG_CONFIG_HOME` per side) and diff number-normalised (`sed 's/[0-9.]\+/N/g'`). The only expected difference is the button count: `+2` per figure row (the two edge `+`s). Update the expected count in `docs/claude/probe-readings.md` in Task 8; do not change anything else in the baseline.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Build, run both probes** (`UDPSCOPE_PROBE_LAYOUT_MS` and `UDPSCOPE_PROBE_SPLIT_MS`, serially). Expected: split-probe lines as designed, `gtk warnings: 0`, no `Gtk-CRITICAL`.
|
||||||
|
|
||||||
|
- [ ] **Step 10: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui.cpp src/ui_layout.cpp src/ui_app.hpp src/ui_probe.cpp
|
||||||
|
git commit -m "layout editor: split a row into cells, resize cells by drag"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Signals page, notices and drop target
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_signal_page.cpp` (~260–285), `src/ui_signals.cpp` (~557, ~570, ~579), `src/ui.cpp` (~325, ~1680, ~2762, ~2766, ~2942)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `RowSet::plot_label(int)`, x-aware `area->row_at(x, y)` from Task 2.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** In `ui_signal_page.cpp` "Plot on" buttons keep their `udpscope-row` data (plot index) and the `assign_signal_to_row` callback but their label becomes `rows->plot_label(r)` and, for split rows, the tooltip "row 2, cell 1 of 2" (`"row %d, cell %d of %d"` with `figure_row_of + 1`, `cell_of + 1`, `cells_in_row`). This amends the spec's "row 2, left" wording to a unified numeric form; a single-cell row keeps its existing tooltip.
|
||||||
|
- [ ] **Step 2:** Replace `"row %d"` / `r + 1` notices at `ui_signals.cpp:557`, `:570`, `ui.cpp:1680`, `:2762`, `:2766` with `"row %s"` and `plot_label(i).c_str()`.
|
||||||
|
- [ ] **Step 3:** Confirm `row_at(app, x, y)` (`ui.cpp:325`) forwards `x` to `area->row_at` (it did not need it before) and that the two callers (`ui_signals.cpp:579`, `ui.cpp:2942`) pass the pointer position unchanged. Persistence layers (`sync_layers`) fill free slots of the same plot, with no change.
|
||||||
|
- [ ] **Step 4:** Extend the split probe: after step 3 of Task 3, call `assign_signal_to_row` for a signal onto plot 1 of a split row and report the notice text / `traces=` showing the signal on `1b`. Expected probe line shows `traces=[…|1b:name…]`.
|
||||||
|
- [ ] **Step 5:** Build, run the split probe and `gtk warnings: 0`; commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui_signal_page.cpp src/ui_signals.cpp src/ui.cpp src/ui_probe.cpp
|
||||||
|
git commit -m "signals: label plots by row and cell, drop onto a cell"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Regression runs
|
||||||
|
|
||||||
|
**Files:** none (verification); `docs/claude/probe-readings.md` is updated in Task 8 for any feature-explained difference.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** Clean build from scratch in a temporary build dir; expect exactly one warning.
|
||||||
|
- [ ] **Step 2:** Serially run the probes listed in `docs/claude/verification.md` that touch layout, cursors, measurements, readouts and the E-1608 source (one mock per run, killed afterwards). For each, run once on the pre-feature commit (`git worktree` or checkout of `HEAD` before Task 1, built separately) and once now with a fresh `XDG_CONFIG_HOME`; diff number-normalised. The only acceptable differences are those the feature explains (layout probe's button count, new `cells`/column-weights lines in layout dumps).
|
||||||
|
- [ ] **Step 3:** Layout dumps and probes that print per-row layout gain a `cells=[…]` list and a `colweights=[…]` line: add those to `layout_probe_report` and to the layout dump in `ui.cpp`, then re-run to confirm the numbers.
|
||||||
|
- [ ] **Step 4:** Run the plotter suite once more (`deps/plotter/build`, all `test_*`).
|
||||||
|
- [ ] **Step 5:** Commit any report-line additions: `git add src/ui_probe.cpp src/ui.cpp && git commit -m "probes: print cells and column weights"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Docs
|
||||||
|
|
||||||
|
**Files:** `README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/` (layout page), `docs/claude/probe-readings.md`, `docs/claude/verification.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/build-and-run.md`, `src/ui_context_help.cpp`, `CLAUDE.md` (only if the "figure of up to eight stacked rows" sentence needs "plots").
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write each doc paragraph** (real text, in the style of the neighbouring sections):
|
||||||
|
- `build-and-run.md` (vendored plotter changes list): "`plot::Figure` rows can hold several plots: `Figure(std::span<const u32> cells_per_row)`, `plot_count()`, `cells_in_row()`, `first_plot_of_row()`, `row_of_plot()`, `set_cell_weights()`, `set_col_gap()`, `FigureLayout::cells`/`col_gap`. `Figure(u32 rows)` still means one cell per row. Cell widths use the height scheme with a 64 px (logical) floor; the GTK4 and GLFW adapters route pointer events by cell rect."
|
||||||
|
- `invariants-plot-and-layout.md` (cell rules): plots are indexed flat; `row_count()` is the plot count and `figure_row_count()` the rows; the x link, the crosshair and the vertical cursor pair are shared by all cells; y axes, horizontal rulers and fit are per cell; only the bottom row's cells carry x labels; the left gutter is one maximum for the whole figure; caps 4 cells per row / 8 plots; the editor's `+` and drag rules.
|
||||||
|
- `verification.md` and `probe-readings.md`: `UDPSCOPE_PROBE_SPLIT_MS`, each `[split-probe]` line, the reading it must give (`tile=1 x_equal=1 cursors_aligned=1`, `rt=1`, legacy file → one cell per row), and the new layout-probe button count / the `cells` and `colweights` fields.
|
||||||
|
- `README.md` and `docs/USER-GUIDE.md`: a "Splitting a row" subsection: Layout editor → `+` at a row's left/right edge or between cells; drag the arrow to resize; `×` removes a cell; all cells share one time window; limits; session `row`/`width` keys.
|
||||||
|
- Mallard layout page and `ui_context_help.cpp` layout-editor text: the same user-facing paragraph, in the page's format.
|
||||||
|
- [ ] **Step 2:** Build (the help page is validated at build/install, if the project has a `yelp-check` target run it) and run the context-help probe if `verification.md` lists one.
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md docs/USER-GUIDE.md help docs/claude src/ui_context_help.cpp CLAUDE.md
|
||||||
|
git commit -m "docs: horizontal plot split"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- **Spec coverage:** shared time window (Task 1 x link test, Task 3 probe `x_equal`); per-row 1..4 cells and caps (Tasks 3, 5); draggable widths saved (Tasks 4, 5); plotter API and layout rules including narrow degradation, bottom-only labels, common gutter (Task 1); hit-testing by cell (Task 2); host model, flattened indices, caps, cursors/crosshair/y axes per cell (Tasks 1, 3); drop onto a cell and "place on row" listing (Task 6, tooltip wording amended to "row 2, cell 1 of 2"); layout editor controls (Task 5); session format and legacy loading (Task 4); verification probes and regression (Tasks 3–5, 7); docs list (Task 8).
|
||||||
|
- **Placeholders:** accessor names that the plan could not confirm without re-reading code (`layout_info()`, `rs.plot(p)`, `axis_x`, `cursor_x`, `carry_row` argument order, `Status` constructors) are flagged where used with how to resolve them from the neighbouring code; no behaviour is left unspecified.
|
||||||
|
- **Type consistency:** `insert_cell(fr, at)`, `remove_cell(plot)`, `set_cell_weights(fr, vector<float>)`, `set_cells_per_row(vector<int>)`, `plot_label(plot)`, `figure_row_count()`, `cells_in_row(fr)`, `first_plot_of_row(fr)`, `figure_row_of(plot)`, `cell_of(plot)` are used with the same signatures in Tasks 3–6; plotter names (`plot_count`, `cells_in_row`, `set_cell_weights(u32, span<const f32>)`, `set_col_gap`, `FigureLayout::cells/col_gap`) match across Tasks 1–3.
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
# Split rows share one y axis -- Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** The cells of a split row share one y range and one y axis (labels on the leftmost cell only), as the figure's rows share one x window and one x axis.
|
||||||
|
|
||||||
|
**Architecture:** The vendored plotter's `Figure` gets `set_share_row_y(bool)`: when on, every figure row with more than one cell keeps its cells' y axis 0 in step (range and auto flag) from `Figure::layout()`, and every cell but the first loses its y gutter (`YStrip::None`). The host turns it on in `RowSet::rebuild_all()`, mirrors the ruler pair across a row, and maps the layout editor's divider drag through plot widths.
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4/libadwaita, the vendored `deps/plotter` (git submodule; commit inside it first, then the pin in the main repo), headless probes under `xvfb-run`.
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-11-03-split-shared-y-design.md` (amends `2026-11-03-horizontal-split-design.md`).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Shared y is always on for split rows, no option; an unsplit row behaves exactly as before. (spec Decisions 1)
|
||||||
|
- Only the leftmost cell of a row has a y gutter and y labels; the choice is structural (column index), never from content. (Decisions 2; "layout must not depend on content", `docs/claude/invariants-plot-and-layout.md`)
|
||||||
|
- `Figure::set_share_row_y` defaults to off so existing figures and every existing test in `deps/plotter/tests` are unchanged.
|
||||||
|
- Cell rects still tile the row exactly (last cell closes the row); no rect is ever negative; `Figure(u32 rows)` and `{1}` rows lay out as today.
|
||||||
|
- The row's y gutter is taken off the row width first; the cells then share the rest by the existing `floor + extra*w/total` rule (floor `kMinCellWidthLogical = 64` x ui_scale), so equal weights give equal plot areas. The first cell's rect is gutter + its plot width.
|
||||||
|
- Rulers: one pair per figure row, drawn at the same y in every cell; a drag in any cell moves them. Measure level marks, readouts, measurements stay per trace / per plot index.
|
||||||
|
- Session file unchanged (y ranges were never persisted).
|
||||||
|
- Every source file carries the SPDX MIT header (existing files already do).
|
||||||
|
- Verification is by the plotter suite and the app's headless probes only. Build: exactly 1 warning (baseline). Probes: `gtk warnings: 0`, no `Gtk-CRITICAL`. Run recipe in `docs/claude/verification.md`; use a fresh throwaway `XDG_CONFIG_HOME` per run (never write `~/.config/udpscope`); run probes short and serial; kill the mock afterwards. The plotter test `staging_a_glyph_that_is_not_the_cell_aborts_in_a_debug_build` may hang: run the suite under `ulimit -c 0; timeout 300`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| `deps/plotter/include/plot/plot/plot.hpp`, `src/plot/plot.cpp` | `YStrip`, `set_y_strip_mode`, `set_y_link_member`, `y_data_bounds`, `fit_y_bounds`; skip autoscale of a linked y axis; zero y chrome for `YStrip::None` |
|
||||||
|
| `deps/plotter/include/plot/plot/figure.hpp`, `src/plot/figure.cpp` | `set_share_row_y`, `row_y_auto`, `sync_row_y()`, strip modes, gutter maximum over leftmost cells, plot-width cell placement |
|
||||||
|
| `deps/plotter/src/render/renderer.cpp` (+ adapters if needed) | no y labels for a `None` cell |
|
||||||
|
| `deps/plotter/tests/test_figure.cpp` | new cases |
|
||||||
|
| `src/plot_host.cpp`, `src/ui.cpp`, `src/ui_cursors.cpp`, `src/ui_probe.cpp` | host wiring, rulers, pane, drag, probes |
|
||||||
|
| docs | see Task 5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Plotter -- y link of a row's cells (range and auto)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `deps/plotter/include/plot/plot/plot.hpp`, `deps/plotter/src/plot/plot.cpp`
|
||||||
|
- Modify: `deps/plotter/include/plot/plot/figure.hpp`, `deps/plotter/src/plot/figure.cpp`
|
||||||
|
- Test: `deps/plotter/tests/test_figure.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (Plot): `void set_y_link_member(u32 axis, bool linked) noexcept;` `bool y_data_bounds(u32 axis, Range& out) const;` (union of the visible traces' y bounds on that axis, false if none) `bool fit_y_bounds(u32 axis, Range bounds);` (the axis' own padded fit of `bounds`, as `fit_y_axis` does; leaves `auto_scale` alone).
|
||||||
|
- Produces (Figure): `void set_share_row_y(bool on);` `bool share_row_y() const noexcept;` `bool row_y_auto(u32 row) const noexcept;` (figure row; true for a row with fewer than two cells or when sharing is off, i.e. "no group").
|
||||||
|
- Behaviour: with sharing on, for each figure row with `n > 1` cells, y axis 0 of its cells is one group. A range set on any member (wheel, drag, `set_y_range`) becomes the group's and is pushed to all; the group is auto or manual as one; auto fits the union of all members' visible traces (cells without a trace ignored; padding/log handling is `fit_axis_range`'s, via `fit_y_bounds` on the first member). Members' own autoscale is suppressed.
|
||||||
|
|
||||||
|
Group state per row (`Figure::RowY`): `Range pushed; bool have = false; bool auto_scale = true;`. Sync, run from `Figure::layout()` right after `sync_x_links()` (before any `Plot::layout()` call, because that is where a plot would otherwise autoscale):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void Figure::sync_row_y() {
|
||||||
|
if (!share_row_y_) return;
|
||||||
|
row_y_.resize(cells_per_row_.size());
|
||||||
|
for (u32 row = 0; row < cells_per_row_.size(); ++row) {
|
||||||
|
const u32 n = cells_per_row_[row];
|
||||||
|
if (n < 2u) continue;
|
||||||
|
RowY& g = row_y_[row];
|
||||||
|
const u32 first = row_first_[row];
|
||||||
|
if (!g.have) {
|
||||||
|
g.pushed = plots_[first].y_axis(0).range;
|
||||||
|
g.auto_scale = plots_[first].y_axis(0).auto_scale;
|
||||||
|
}
|
||||||
|
// What happened since the last push. A member's auto flag that differs from the group's is a
|
||||||
|
// request (set_y_range clears it, auto_fit_y / set_y_auto_scale(true) sets it); otherwise a
|
||||||
|
// member whose range differs from the pushed one was moved by a gesture.
|
||||||
|
bool want_auto = g.auto_scale;
|
||||||
|
Range target = g.pushed;
|
||||||
|
bool flag_request = false;
|
||||||
|
for (u32 i = first; i < first + n && !flag_request; ++i) {
|
||||||
|
const Axis& a = plots_[i].y_axis(0);
|
||||||
|
if (a.auto_scale != g.auto_scale) {
|
||||||
|
want_auto = a.auto_scale;
|
||||||
|
if (!want_auto) target = a.range; // auto -> manual: the gesture's range
|
||||||
|
flag_request = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!flag_request && !want_auto) {
|
||||||
|
for (u32 i = first; i < first + n; ++i) {
|
||||||
|
const Range r = plots_[i].y_axis(0).range;
|
||||||
|
if (r.min != g.pushed.min || r.max != g.pushed.max) { target = r; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (want_auto) {
|
||||||
|
Range bounds{};
|
||||||
|
bool any = false;
|
||||||
|
for (u32 i = first; i < first + n; ++i) {
|
||||||
|
Range b{};
|
||||||
|
if (!plots_[i].y_data_bounds(0, b)) continue;
|
||||||
|
bounds = any ? Range{std::min(bounds.min, b.min), std::max(bounds.max, b.max)} : b;
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
if (any && plots_[first].fit_y_bounds(0, bounds)) target = plots_[first].y_axis(0).range;
|
||||||
|
// else: nothing to fit -- keep the range the group had
|
||||||
|
}
|
||||||
|
for (u32 i = first; i < first + n; ++i) {
|
||||||
|
plots_[i].set_y_range(0, target); // clears the flag ...
|
||||||
|
plots_[i].set_y_auto_scale(0, want_auto); // ... so restate it: every member mirrors the group
|
||||||
|
}
|
||||||
|
g.pushed = target;
|
||||||
|
g.auto_scale = want_auto;
|
||||||
|
g.have = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`set_share_row_y(on)` stores the flag, clears `row_y_`, and calls `set_y_link_member(0, on)` on every cell of every row with `n > 1` (cells whose plot has no y axis yet are fine: `set_y_link_member` ignores a missing axis, so also re-apply in `sync_row_y()` before the loop so axes added after the call are covered). `Plot::autoscale_axes()` must skip a y axis whose `link_member` is set (reuse `Axis::link_member`, currently x-only in meaning; update its comment). `y_data_bounds` is `trace_bounds_union(false, axis, out)`. `fit_y_bounds` is `return axis < y_axes_.size() && fit_axis_range(y_axes_[axis], bounds);`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** -- append to `deps/plotter/tests/test_figure.cpp` (they use the existing `split_figure`, `whole_figure`, `plot::data::ArraySeries`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
PLOT_TEST(figure_row_y_cells_share_a_range_after_a_gesture) {
|
||||||
|
plot::Figure figure = split_figure({2, 1});
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
figure.layout();
|
||||||
|
figure.plot(1u).set_y_range(0u, {-3.0, 7.0}); // what a wheel / drag / pan ends in
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, -3.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 7.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).min, -3.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 7.0, 1e-9);
|
||||||
|
// The unsplit row is its own thing.
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(2u).y_range(0u).min, -1.0, 1e-9);
|
||||||
|
PLOT_CHECK(!figure.row_y_auto(0u));
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_auto_fits_the_union_and_ignores_empty_cells) {
|
||||||
|
plot::Figure figure = split_figure({3});
|
||||||
|
for (plot::u32 i = 0; i < 3u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
|
||||||
|
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f, 5.0f}, 0.0, 1.0);
|
||||||
|
plot::data::ArraySeries b(std::vector<plot::f32>{-20.0f, 5.0f, 0.0f}, 0.0, 1.0);
|
||||||
|
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
||||||
|
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
|
||||||
|
figure.set_share_row_y(true); // cell 2 has no trace
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.row_y_auto(0u));
|
||||||
|
const plot::Range r0 = figure.plot(0u).y_range(0u);
|
||||||
|
PLOT_CHECK(r0.min <= -20.0 && r0.max >= 10.0);
|
||||||
|
PLOT_CHECK(r0.max - r0.min < 40.0); // padded, not blown up
|
||||||
|
for (plot::u32 i = 1; i < 3u; ++i) {
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).min, r0.min, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(i).y_range(0u).max, r0.max, 1e-9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_manual_then_fit_returns_to_auto) {
|
||||||
|
plot::Figure figure = split_figure({2});
|
||||||
|
for (plot::u32 i = 0; i < 2u; ++i) figure.plot(i).set_y_auto_scale(0u, true);
|
||||||
|
plot::data::ArraySeries a(std::vector<plot::f32>{0.0f, 10.0f}, 0.0, 1.0);
|
||||||
|
plot::data::ArraySeries b(std::vector<plot::f32>{-4.0f, 4.0f}, 0.0, 1.0);
|
||||||
|
figure.plot(0u).add_trace({&a, plot::Color::hex(0x40C0FF), 1.5f, 0u, 0u});
|
||||||
|
figure.plot(1u).add_trace({&b, plot::Color::hex(0xFF8080), 1.5f, 0u, 0u});
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
figure.layout();
|
||||||
|
figure.plot(1u).set_y_range(0u, {1.0, 2.0});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(!figure.row_y_auto(0u));
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, 1.0, 1e-9);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 2.0, 1e-9);
|
||||||
|
figure.layout(); // manual holds: no fight with the plots' own autoscale
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).y_range(0u).max, 2.0, 1e-9);
|
||||||
|
figure.plot(0u).auto_fit_y(0u); // what fit-all / double-click does to one cell
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.row_y_auto(0u));
|
||||||
|
PLOT_CHECK(figure.plot(1u).y_range(0u).min <= -4.0 && figure.plot(1u).y_range(0u).max >= 10.0);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).min, figure.plot(1u).y_range(0u).min, 1e-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_sharing_off_keeps_cells_independent) {
|
||||||
|
plot::Figure figure = split_figure({2});
|
||||||
|
figure.layout();
|
||||||
|
figure.plot(1u).set_y_range(0u, {-3.0, 7.0});
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).y_range(0u).max, 1.0, 1e-9);
|
||||||
|
PLOT_CHECK(!figure.share_row_y());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run -- expect compile failure** (`set_share_row_y` undeclared). Build and run the plotter tests per `docs/claude/build-and-run.md` (the plotter's own CMake test target; run the binary with `ulimit -c 0; timeout 300`).
|
||||||
|
- [ ] **Step 3: Implement** the Plot additions, `Figure` members (`bool share_row_y_ = false; struct RowY {...}; std::vector<RowY> row_y_;`), `set_share_row_y`, `share_row_y`, `row_y_auto` (`row < row_y_.size() && row_y_[row].have ? row_y_[row].auto_scale : true`), `sync_row_y` as above (declare it private next to `sync_x_links`), and call `sync_row_y();` in `Figure::layout()` immediately after `sync_x_links();`. Doc comments in the file's style (what, and why the group, not the plot, owns auto scale).
|
||||||
|
- [ ] **Step 4: Run the whole plotter suite.** Expect all new cases pass and 0 failed overall (was 407 cases).
|
||||||
|
- [ ] **Step 5: Commit** inside the submodule: `git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: cells of a row can share one y range"`. Do not touch the pin yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Plotter -- one y axis per row (strip mode, gutter, cell widths)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `deps/plotter/include/plot/plot/plot.hpp`, `deps/plotter/src/plot/plot.cpp` (`Plot::layout`, ~lines 1286-1390)
|
||||||
|
- Modify: `deps/plotter/src/plot/figure.cpp` (`Figure::layout`, ~lines 550-625)
|
||||||
|
- Modify: `deps/plotter/src/render/renderer.cpp` (~lines 900-920, y label staging)
|
||||||
|
- Test: `deps/plotter/tests/test_figure.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1's `set_share_row_y`.
|
||||||
|
- Produces: `enum class YStrip { Both, None };` next to `XStrip` in `plot.hpp`; `Plot::set_y_strip_mode(YStrip)` / `y_strip_mode()`. With `None`, `Plot::layout()` reserves no y label width and no y title width (`y_axis_width == 0`, `y_title_width == 0`, `y_label_area.w == 0`) and ignores `min_y_gutter_`; the grid lines are still built. With sharing on, `Figure::layout()` sets `None` on every cell but the first of its row; the common-gutter maximum is taken over `Both` cells only; `None` cells get `set_min_y_gutter(0)`.
|
||||||
|
- Cell widths with sharing on, for a row with `n > 1` cells: after the gutter `G` is known (end of the measurement pass), `G_eff = min(G, row.w / 2)`; call `place_cells` on the rect `{row.x + G_eff, row.y, row.w - G_eff, row.h}`, then grow cell 0 to the left by `G_eff` (`x -= G_eff; w += G_eff`). Update `layout_.cells`, call `set_viewport` on each cell, then run the final `p.layout()` pass. With sharing off nothing changes.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests** (append to `test_figure.cpp`; `give_magnitude_row` and `split_figure` exist):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
PLOT_TEST(figure_row_y_only_the_first_cell_has_a_gutter) {
|
||||||
|
plot::Figure figure = split_figure({1, 3}, false);
|
||||||
|
for (plot::u32 i = 0; i < figure.plot_count(); ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.plot(0u).layout().y_axis_width > 0.0f); // unsplit row keeps its axis
|
||||||
|
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f); // first cell of the split row
|
||||||
|
for (plot::u32 i = 2; i < 4u; ++i) {
|
||||||
|
const plot::PlotLayout& l = figure.plot(i).layout();
|
||||||
|
PLOT_CHECK(l.y_axis_width == 0.0f && l.y_title_width == 0.0f && l.y_label_area.w == 0.0f);
|
||||||
|
PLOT_CHECK(l.axes.w > 0.0f);
|
||||||
|
}
|
||||||
|
// The gutter is one figure-wide value: the unsplit row and the split row's first cell agree.
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(0u).layout().axes.x - figure.layout().cells[0].x,
|
||||||
|
figure.plot(1u).layout().axes.x - figure.layout().cells[1].x, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_equal_weights_give_equal_plot_widths_and_cells_tile) {
|
||||||
|
plot::Figure figure = split_figure({3}, false);
|
||||||
|
for (plot::u32 i = 0; i < 3u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||||
|
figure.set_col_gap(8.0f);
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
const plot::FigureLayout& fl = figure.layout();
|
||||||
|
const double w0 = figure.plot(0u).layout().axes.w;
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(1u).layout().axes.w, w0, 0.6);
|
||||||
|
PLOT_CHECK_NEAR(figure.plot(2u).layout().axes.w, w0, 0.6);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[0].x, fl.rows[0].x, 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].right(), fl.rows[0].right(), 0.001);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[1].x - fl.cells[0].right(), 8.0, 0.01);
|
||||||
|
PLOT_CHECK_NEAR(fl.cells[2].x - fl.cells[1].right(), 8.0, 0.01);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_weights_apply_to_plot_widths) {
|
||||||
|
plot::Figure figure = split_figure({2}, false);
|
||||||
|
for (plot::u32 i = 0; i < 2u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||||
|
figure.set_col_gap(0.0f);
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
const std::vector<plot::f32> w{3.0f, 1.0f};
|
||||||
|
PLOT_CHECK(figure.set_cell_weights(0u, w));
|
||||||
|
figure.layout();
|
||||||
|
const double a0 = figure.plot(0u).layout().axes.w;
|
||||||
|
const double a1 = figure.plot(1u).layout().axes.w;
|
||||||
|
PLOT_CHECK_NEAR(a0 - 64.0, (a1 - 64.0) * 3.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_narrow_row_degrades_without_negative_rects) {
|
||||||
|
plot::Figure figure = split_figure({4}, false);
|
||||||
|
figure.set_viewport(plot::Rect{0.0f, 0.0f, 30.0f, 100.0f});
|
||||||
|
for (plot::u32 i = 0; i < 4u; ++i) give_magnitude_row(figure.plot(i), 0u);
|
||||||
|
figure.set_col_gap(10.0f);
|
||||||
|
figure.set_share_row_y(true);
|
||||||
|
double sum = 0.0;
|
||||||
|
for (const plot::Rect& c : figure.layout().cells) {
|
||||||
|
PLOT_CHECK(c.w >= 0.0f && c.h >= 0.0f);
|
||||||
|
sum += c.w;
|
||||||
|
}
|
||||||
|
PLOT_CHECK_NEAR(sum, 30.0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
PLOT_TEST(figure_row_y_sharing_off_still_gives_every_cell_a_gutter) {
|
||||||
|
plot::Figure figure = split_figure({2}, false);
|
||||||
|
give_magnitude_row(figure.plot(0u), 0u);
|
||||||
|
give_magnitude_row(figure.plot(1u), 1u);
|
||||||
|
figure.layout();
|
||||||
|
PLOT_CHECK(figure.plot(1u).layout().y_axis_width > 0.0f);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Plus a renderer check: find how existing tests count or inspect staged text (see `tests/test_render_smoke.cpp` / the `staging_a_glyph_...` neighbourhood). Add one case that a `None` cell stages no y tick labels (the staged label count of a two-cell shared row equals that of a one-cell row of the same height, plus nothing for the second cell's y side). If the harness cannot observe staged text, instead assert in the renderer change below with a comment and report that no automated check was possible.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run -- expect failures** (the gutter/width cases; `YStrip` undeclared).
|
||||||
|
- [ ] **Step 3: Implement.**
|
||||||
|
- `Plot::layout`: `const bool y_none = y_strip_ == YStrip::None;` seed `y_width = y_none ? 0 : ...`, `y_title_w = y_none ? 0 : ...`, `y_floor = y_none ? 0 : min(min_y_gutter_, y_budget)`, and `need_y = y_none ? 0 : ...` in the fixed-point test so the loop still settles in two passes. Read the loop first; every use of the y strip widths must collapse to 0 and the `axes` must start at `viewport_.x`.
|
||||||
|
- `Figure::layout`: before the viewport loop compute `const bool shared = share_row_y_ && cells_per_row_[row] > 1u;` and set `p.set_y_strip_mode(shared && i != row_first_[row] ? YStrip::None : YStrip::Both)` (reset to `Both` when not shared). In the gutter maximum loop skip `None` plots and give them `set_min_y_gutter(0.0f)`. After the measurement pass and before the final `p.layout()` loop, re-place the cells of each shared row as specified in Interfaces and re-apply `set_viewport`.
|
||||||
|
- Renderer: do not stage y tick labels (and y title) when the plot's `layout().y_label_area.w <= 0.0f`; read the staging code at `renderer.cpp:~906-917` first. Check `src/input/controller.cpp:~524` still cannot start a y axis-drag in a zero-width label area (it hit-tests `y_label_area`); if it can, guard it.
|
||||||
|
- Check both adapters (`adapters/gtk4`, `adapters/glfw`) route by cell rect only, so nothing else changes.
|
||||||
|
- [ ] **Step 4: Run the whole plotter suite.** Expect 0 failed, including all pre-existing figure cases (`figure_cells_common_y_gutter` still reads the old behaviour because sharing defaults off).
|
||||||
|
- [ ] **Step 5: Commit** in the submodule: `git -C deps/plotter add -u && git -C deps/plotter commit -m "figure: shared-y rows draw one y axis, on the first cell"`. Then in the main repo: `git add deps/plotter && git commit -m "pin deps/plotter: shared y axis for split rows"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Host -- turn sharing on; rulers per row; pane and units
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/plot_host.cpp` (`rebuild_all`, `sync_cursors`, `place_cursors` call sites), `src/plot_host.hpp` if a helper is declared
|
||||||
|
- Modify: `src/ui.cpp` (`row_unit` ~1759, cursors-pane line construction ~1840-1855), `src/ui_cursors.cpp` (~55-70)
|
||||||
|
- Modify: `src/ui_probe.cpp` (split probe: new steps), `src/ui_app.hpp` if a probe state is needed
|
||||||
|
- Test: the split probe (`UDPSCOPE_PROBE_SPLIT_MS`), layout probe
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 2's `Figure::set_share_row_y(bool)` and its pin.
|
||||||
|
- Produces: no new public host API required. Behaviour: after `rebuild_all()` every split figure row shares y; `RowSet::set_y_auto/set_y_range/fit_all` need no change (they call per-plot setters that the figure's sync turns into row-wide effects).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Probe first (RED).** Add a new split-probe step after the existing steps (follow the existing step style in `ui_probe.cpp` near the `[split-probe] ...` lines; note the next free step index) that, on a figure with a split row holding a trace in each cell:
|
||||||
|
1. `y_shared`: set the y range on cell A via `rs.set_y_range(a, -3, 7)`, pump a frame, assert cell B's `plot->y_range(axis_y)` equals it.
|
||||||
|
2. `y_gutter`: cell B (non-first) `layout().y_axis_width == 0` and `y_label_area.w == 0`; cell A `> 0`; and `axes.w` of A and B equal within 1 px with default weights.
|
||||||
|
3. `y_fit`: `rs.fit_all()` then one frame: both cells' ranges equal and cover both traces' y bounds.
|
||||||
|
4. `ruler_mirror`: with cursor mode showing rulers, set cell A's ruler (via `set_cursor_y` on its plot) then call `rs.sync_cursors(a)`; `ruler_y(a)==ruler_y(b)` and `ruler_b_y` likewise.
|
||||||
|
Print one line: `[split-probe] shared-y y_shared=%d y_gutter=%d y_fit=%d ruler_mirror=%d ranges=[...] axes_w=[..]`. Build and run it; expect `y_shared=0 y_gutter=0` (sharing not wired) -- RED.
|
||||||
|
- [ ] **Step 2: Wire it.**
|
||||||
|
- `RowSet::rebuild_all()`: call `figure_->set_share_row_y(true);` right after the `Figure` is created (before `install`, so the axes added by `install` are picked up -- Task 1 re-applies membership in `sync_row_y`).
|
||||||
|
- `RowSet::sync_cursors(int drag_row)`: after `r.ruler_y/ruler_b_y` are read back from the dragged cell, copy both values to every other cell of the same figure row (`first_plot_of_row(figure_row_of(drag_row))` .. `+ cells_in_row`) and `place_cursors(that, plot::CursorPlacement::Free)` on them.
|
||||||
|
- `reset_cursors()` already writes each plot's rulers from its y range; confirm both cells of a row get identical values (the range is shared). Rulers that exist before the row was split: when `insert_cell` creates the new cell, copy the row's first cell's `ruler_y/ruler_b_y` into it (read `insert_cell` and `copy`/reset helpers near `plot_host.cpp:224-240`).
|
||||||
|
- Cursors pane (`ui.cpp:~1840`): the ruler line (`slot = -1`) is created once per figure row, for the row's first plot only (`app.rows->cell_of(r) == 0`). Label: unsplit rows keep today's wording; a split row uses `"row N"` with the same numbering `plot_label` uses for the row (without the cell letter). Check `ui_cursors.cpp:~60` and every other reader of `cursors_lines` / `slot == -1` for assumptions of one ruler line per plot, and update the probe line `ruler-labels` accordingly (its expected value changes by design -- say so in the report).
|
||||||
|
- `row_unit(app, row)` (`ui.cpp:~1759`): join the units over every cell of `row`'s figure row (loop `first_plot_of_row .. + cells_in_row`, same lock), "" if they differ.
|
||||||
|
- `sync_compare_axes` (`ui_events.cpp:~555`): leave as is; the probe in Step 3 includes a comparison-mode check only if the existing compare probe covers split rows, otherwise note it in the report as unverified.
|
||||||
|
- `ui.cpp:~4237` gutter reading takes a max over all plots and `~4361` reads plot 0: both stay correct (None cells read 0). Confirm by reading, change nothing.
|
||||||
|
- [ ] **Step 3: Verify.** Incremental `cmake --build build` (1 warning baseline). Run the split probe and the layout probe per `docs/claude/verification.md` and diff against the baseline commit `18b91c8`-era numbers as in the earlier tasks (number-normalised): only lines this feature explains may differ (`ruler-labels`, `buttons=` unchanged, `cells=` unchanged; widths of non-first cells grow by the gutter). Expect the new line `y_shared=1 y_gutter=1 y_fit=1 ruler_mirror=1`, all earlier split-probe steps still reading as before (col-drag may change -- that is Task 4), `gtk warnings: 0`, no `Gtk-CRITICAL`.
|
||||||
|
- [ ] **Step 4: Commit** `git add src/plot_host.cpp src/plot_host.hpp src/ui.cpp src/ui_cursors.cpp src/ui_probe.cpp src/ui_app.hpp && git commit -m "plot host: split rows share y, rulers per row"` (add only files actually changed).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Layout editor -- divider drag through plot widths
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui.cpp` (`layout_col_drag_begin` ~786, `layout_col_apply` ~755)
|
||||||
|
- Modify: `src/ui_probe.cpp` (the `col-drag` step ~4190-4250), `src/ui_app.hpp` (`layout_col_drag_left0/right0` docs)
|
||||||
|
- Test: split probe `col-drag` line
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Tasks 2-3. A cell's rect width is no longer its weight's width: weights apply to **plot widths** (`plot->layout().axes.w + right_gutter`, i.e. rect width minus the y gutter, which only cell 0 has).
|
||||||
|
- Produces: `layout_col_drag_begin` stores the left/right **plot** widths in logical px (rect width minus `y_axis_width + y_title_width` of that cell's plot; the right cell's is 0). `layout_col_apply` is unchanged otherwise: the 96 px floor and the 64 px plotter floor both refer to plot widths now (`kMinCellPx`, `kPlotterMinCellPx`), the formula `frac = (want_left - 64)/(pair - 128)` stays, and the pair's weight sum stays.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Probe first (RED).** Extend the `col-drag` step so the cells compared are plot widths (use `axes.w` of the two plots, in logical px) on a row whose first cell is the left one, in addition to the existing checks (+80 px tracking within 1 px, both extremes 96 +/- 1, pair sum preserved). With the old begin code the tracking is off by the gutter; expect `ok=0` before the fix. Keep the other split-probe lines as they are.
|
||||||
|
- [ ] **Step 2: Fix** `layout_col_drag_begin` to subtract the gutter from the left cell: `gutter = plot.layout().y_axis_width + plot.layout().y_title_width` of the left plot (0 for a non-first left cell, which has `None`), converted by the same `scale`. Update the comment above `layout_col_apply` to say "plot width".
|
||||||
|
- [ ] **Step 3: Verify.** Build; run the split probe; expect `col-drag ok=1` and the previous steps unchanged; layout probe unchanged; `gtk warnings: 0`.
|
||||||
|
- [ ] **Step 4: Commit** `git add src/ui.cpp src/ui_probe.cpp src/ui_app.hpp && git commit -m "layout editor: column drag measures plot widths, not cell rects"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Docs
|
||||||
|
|
||||||
|
**Files:** `README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/layout.page`, `docs/claude/probe-readings.md`, `docs/claude/verification.md` (new probe line), `docs/claude/invariants-plot-and-layout.md`, `docs/claude/build-and-run.md`, `docs/claude/architecture.md` if it describes per-cell y, `CLAUDE.md` if it mentions per-cell y, `src/ui_context_help.cpp`, `src/ui_tour.cpp` if they say cells have their own y axis.
|
||||||
|
|
||||||
|
- [ ] **Step 1:** `grep -rn -i "per cell\|own y\|each cell\|y axis" README.md docs help src/ui_context_help.cpp src/ui_tour.cpp CLAUDE.md` to find every statement that the cells of a split row have their own y axis or one common gutter "not per column"; rewrite each to the new rule: one y range and one y axis per figure row, labels on the left cell, y zoom/pan/fit/Auto-Fixed act on the whole row, one ruler pair per row, cells share the y gutter's width only through the first cell.
|
||||||
|
- [ ] **Step 2:** `build-and-run.md`: add the plotter change (`set_share_row_y`, y link semantics, `YStrip`, plot-width cell placement) beside the other vendored changes. `invariants-plot-and-layout.md`: replace the "one gutter, not a per-column one" wording with the structural rule (first cell only, a `None` cell contributes 0, never content-dependent). `probe-readings.md` / `verification.md`: document the `[split-probe] shared-y` line and the changed `ruler-labels` / `col-drag` readings with the defect each wrong value names.
|
||||||
|
- [ ] **Step 3:** Build (help/manual and context help compile), run the split probe once more for `gtk warnings: 0`.
|
||||||
|
- [ ] **Step 4: Commit** `git add <the files changed> && git commit -m "docs: split rows share one y axis"`.
|
||||||
@@ -0,0 +1,832 @@
|
|||||||
|
# Signal badges -- step 2 of the badge-bar redesign Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Put the signals on the badge bar: one badge per enabled signal (a colour dot and the name; up to 6, then one "N signals" badge), each opening a per-signal slide-over page, each a drag handle (to a plot row, or to reorder), plus a trailing `+` that opens the full signal list as a slide-over page. The signal list leaves the dock.
|
||||||
|
|
||||||
|
**Architecture:** The existing `signal_panel` (heading, hint, list, math divider) is *reparented* from the temporary dock into the `signals` slide-over page. A new unit, `src/ui_signal_page.cpp`, owns the lazily-created `signal:<name>` pages (colour, calibration, information, "plot on row N", math edit/delete) and keeps them in step with the model. `src/ui_badges.cpp` grows the signal badges (inside a scroller so the bar's minimum width never depends on how many there are). The drop that today puts a dragged list row on a plot becomes `assign_signal_to_row()`, shared by the drop and by the page's buttons; the badge replaces the list row as the drag source.
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4, libadwaita >= 1.8, CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui_probe.cpp` / `src/ui_badges.cpp` probes, never a separate harness.
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` ("Badge bar", "Slide-over panel", rollout step 2). Step 1 plan: `docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md`. Branch: `feature/ui-badges-dock` (continue on it).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
Copied from the spec and the repository's rules; every task includes them.
|
||||||
|
|
||||||
|
- **Layout must not depend on content** (`docs/claude/invariants-plot-and-layout.md`): the badge bar keeps its fixed 36 px height and its minimum width must not grow with the number of signals (the signal badges sit in a `GtkScrolledWindow`, `GTK_POLICY_EXTERNAL` / `GTK_POLICY_NEVER`); the slide-over still overlays and never resizes the plot.
|
||||||
|
- **A `GtkOverlay` child is picked wherever its allocation is**: nothing in this step adds an overlay child.
|
||||||
|
- Signal badges: "one badge per enabled signal, up to 6; beyond that a single 'N signals' badge. A badge shows a colour dot and the name, is a drag handle (reorder, or drop on a plot row, which replaces today's drag from the signal list), and opens that signal's page (colour, calibration, row, math). A trailing `+` opens the `signals` page (the full list, to enable more; the math editor entry)." (spec, verbatim)
|
||||||
|
- Page ids: `trigger`, `signal:<name>`, `signals`, `measure`, `cursors`, `events`, `source-<id>`. This step adds `signals` and `signal:<name>`.
|
||||||
|
- One slide-over page at a time; Esc or a click outside closes it; each page has its own scroller; the open page is not saved.
|
||||||
|
- "Disable, do not hide": a control that does not apply is insensitive and stays on screen (the one precedent kept is the acquire tick, hidden in a file view -- see `rebuild_signal_list`).
|
||||||
|
- Text buttons only for new controls (the `+` is the character, not an icon); no icons in badges. The colour dot is the existing `chip_markup()` disc.
|
||||||
|
- The widget-lifetime trap (`docs/claude/invariants-gtk-and-chrome.md`): a click handler that calls `rebuild_signal_panels()` destroys the page/row that owns the emitting button; hold the emitter with `outlive_the_rebuild(widget)` first. A drop handler never destroys the widgets it was emitted from.
|
||||||
|
- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`).
|
||||||
|
- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`.
|
||||||
|
- Verification runs are short and serial; kill every mock you start; do not re-run to chase cosmetics. Headless needs `GDK_BACKEND=x11` and, for screenshots, `GSK_RENDERER=cairo` + `import -window root`.
|
||||||
|
- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`.
|
||||||
|
|
||||||
|
## Deliberate step-2 deviations from the spec (state them in the final report)
|
||||||
|
|
||||||
|
- **Reorder is in memory only.** The badge order lives in `App::badge_order` for the session; it is not written to the session file (the spec's session section lists only the dock state). A new signal sorts after the ordered ones, in model order.
|
||||||
|
- **Beyond 6 enabled signals the "N signals" badge replaces the per-signal badges *and* the `+`** (it opens the same `signals` page, so a second button for it would be a duplicate). Per-signal pages for those signals are reached by activating a row of the `signals` list (new in this step); drag-to-row for them is by the page's "Plot on" buttons.
|
||||||
|
- **The list's drag source is removed**, as the spec says ("replaces today's drag from the signal list"); `row_drag_sidebar()` goes with it if nothing else calls it.
|
||||||
|
- A signal page opened for a signal that is not acquired shows its acquire tick (so a list row can be activated without first being ticked).
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Create/Modify | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/ui_signal_page.cpp` | Create | The `signal:<name>` pages: ensure/fill/prune, their buttons, the `signal_pages` probe helpers. |
|
||||||
|
| `src/ui_badges.cpp` | Modify | Signal badges, the `+` badge, "N signals" overflow, scroller, reorder drop, drag source, `signal_badges_probe`. |
|
||||||
|
| `src/ui_signals.cpp` | Modify | `listed_signals()`, `assign_signal_to_row()`, `reset_signal_color()`, row-activated -> page, list drag source removed, header comment fixed. |
|
||||||
|
| `src/ui_slideover.cpp` | Modify | `slideover_remove_page()`. |
|
||||||
|
| `src/ui_app.hpp` | Modify | `Badge` fields, `BadgeBar` fields, `App::badge_order` / `App::signal_pages`, declarations. |
|
||||||
|
| `src/ui.cpp` | Modify | Signal panel into the `signals` page; `ShowSignals` opens it; probe env var; `row_drag_sidebar` removal. |
|
||||||
|
| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | The signals step / hint re-pointed to the page; badge text. |
|
||||||
|
| `src/ui_probe.cpp` | Modify | Every probe that measured or walked `signal_panel` / `signal_list` while it lived in the dock. |
|
||||||
|
| `src/ui_widgets.cpp` | Modify | CSS for the signal badge dot/name if needed. |
|
||||||
|
| `CMakeLists.txt` | Modify | Add `src/ui_signal_page.cpp` next to `src/ui_badges.cpp`. |
|
||||||
|
| `README.md`, `docs/claude/*.md` | Modify | Task 5. (`USER-GUIDE.md` and the Mallard manual are spec step 4.) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: The `signals` page and the `+` badge
|
||||||
|
|
||||||
|
The list moves out of the dock into a slide-over page; the `+` badge and the `g` key open it. Nothing else changes yet (the list rows keep their menu and their drag source).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui.cpp`, `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui_signals.cpp` (header comment), `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `src/ui_probe.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes (step 1): `slideover_add_page(App&, const std::string& id, const std::string& title, GtkWidget* content) -> bool`, `slideover_open/close/toggle(App&, ...)`, `slideover_page(const App&) -> std::string`, `Badge{button,name,state,page}`, `make_badge(...)`, `BadgeBar{bar,signals_slot,system_slot,trigger,source}`.
|
||||||
|
- Produces: `BadgeBar::plus` (`Badge`, page `"signals"`, name label `"+"`); the `signals` page registered once in `on_activate`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Freeze today's self-check**
|
||||||
|
|
||||||
|
Build `HEAD` (`cmake --build build -j`), then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-signals-base.log 2>&1
|
||||||
|
kill $MOCK
|
||||||
|
sed 's/[0-9.]\+/N/g' /tmp/ui-signals-base.log > .superpowers/sdd/ui-signals-baseline-udps.txt
|
||||||
|
grep -c 'Gtk-CRITICAL' /tmp/ui-signals-base.log; grep 'gtk warnings' /tmp/ui-signals-base.log
|
||||||
|
```
|
||||||
|
Expected: `0` and `gtk warnings: 0`. Every later task diffs its own normalised run against this file and must explain every differing line from that task's "allowed baseline changes"; any other difference is a defect.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Fix the stray `\n` in `src/ui_signals.cpp` line 4**
|
||||||
|
|
||||||
|
The header comment reads `...with its colour chip and acquire tick, the panel around it,\n// and the drop that puts a signal on a row.` on one line (a literal backslash-n). Replace it with two comment lines:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// The signal list: one row per signal with its colour chip and acquire tick, the panel around it,
|
||||||
|
// and the drop that puts a signal on a row.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Reparent the panel into a `signals` page (`src/ui.cpp`, `on_activate`, ~line 5172)**
|
||||||
|
|
||||||
|
Replace
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
app->signal_panel = build_signal_panel(*app);
|
||||||
|
gtk_box_append(GTK_BOX(app->dock_page), app->signal_panel);
|
||||||
|
gtk_box_append(GTK_BOX(app->dock_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL));
|
||||||
|
gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel);
|
||||||
|
```
|
||||||
|
with
|
||||||
|
```cpp
|
||||||
|
app->signal_panel = build_signal_panel(*app);
|
||||||
|
gtk_box_append(GTK_BOX(app->dock_page), app->cursors_panel);
|
||||||
|
```
|
||||||
|
and register the page where the other pages are registered (after `build_slideover`, next to the `"trigger"` page; the panel must exist first, so if that registration happens before line 5172, add the `slideover_add_page(*app, "signals", "Signals", app->signal_panel);` call right after the panel is built instead):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
slideover_add_page(*app, "signals", "Signals", app->signal_panel);
|
||||||
|
```
|
||||||
|
Read the surrounding code before editing: the registration must happen once, after `build_slideover(*app)` and after `build_signal_panel`. Update the comments that say the dock holds "the signal list, the cursors and the measurements" (`dock_show_section`, `ui_app.hpp` `dock_page`) to "the cursors and the measurements".
|
||||||
|
|
||||||
|
- [ ] **Step 4: `g` opens the page (`run_app_action`, ~line 4682)**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
case AppAction::ShowSignals: slideover_toggle(app, "signals"); break;
|
||||||
|
```
|
||||||
|
and in `dock_show_section` delete the `"signals"` branch (the dock no longer holds the list); keep `"measure"`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: The `+` badge (`src/ui_badges.cpp`, `src/ui_app.hpp`)**
|
||||||
|
|
||||||
|
In `BadgeBar` add `Badge plus;`. In `build_badge_bar`, after the Source badge:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// The full signal list. Insensitive until the stream has declared signals ("disable, do not hide").
|
||||||
|
make_badge(app, bb.plus, bb.signals_slot, "+", "signals");
|
||||||
|
gtk_widget_set_tooltip_text(bb.plus.button, "all signals: switch more on, add a math channel");
|
||||||
|
```
|
||||||
|
(`make_badge` appends a second, empty `state` label; for `+` leave it empty, it takes no width.) In `sync_badge_bar`, before the early return at the `bb.source.button == nullptr` check:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (bb.plus.button != nullptr) {
|
||||||
|
gtk_widget_set_sensitive(bb.plus.button, !app.model->signals().empty());
|
||||||
|
set_open(bb.plus.button, slideover_page(app) == "signals");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Step 2's later tasks insert the signal badges *before* the `+` in `signals_slot`; Task 3 handles the ordering, so do not worry about it here.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Tour and context help**
|
||||||
|
|
||||||
|
`src/ui_tour.cpp`: the `"signals"` step (line ~460) becomes `{"signals", "signals", "The signals", ...}` (second field is the slide-over page; see the `"trigger"` step), text: `"Every signal the source declares, each with the switch that acquires it. The + badge opens this list; a signal has to be switched on before it can be plotted, measured or searched."`. The `"side"` step text becomes `"The dock beside the plot: the cursors, then the measurements. The buttons in the bar above show and hide it."`. Keep `&App::signal_list` as the rect. `src/ui_context_help.cpp` line ~92: replace the last sentence of the `"signals"` hint (`A signal that is dragged onto a plot is drawn there.`) with `A signal's badge, dragged onto a plot, draws it there.`; the `"signals_config"` hint's `which plot a signal is dragged onto` becomes `which plot a signal is drawn on`.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Re-point the probes that treated the list as part of the dock**
|
||||||
|
|
||||||
|
`src/ui_probe.cpp` references `signal_panel`/`signal_list` at ~103-186, ~1828, ~1853, ~3843-3847, ~5827, ~6165, ~6431 and the tour-surface probe ~7024-7097. The page is closed by default, so widgets inside it are unmapped and have no allocation. For each: (a) a probe that measures the panel's allocated size -- open the `signals` page first (`slideover_open(*a, "signals")`) and close it after, or measure with `gtk_widget_measure`; (b) a probe that walks the rows needs nothing (children exist while unmapped); (c) the stress line at ~1853 (`gtk_widget_set_size_request(a->signal_panel, -1, 3000)`) existed to overflow the *dock's* scroller; re-point it to `a->cursors_panel`; (d) the `ShowSignals` check at ~6431 asserts `slideover_page(*a) == "signals"` and then closes it; (e) `~6165` (`gtk_widget_get_parent(app.signal_list)`) -- read what it needs and take it from the page instead. Print the same lines as before wherever the value is still meaningful.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build -j 2>&1 | grep -E 'warning|error'; echo "rc=$?"
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-signals-t1.log 2>&1
|
||||||
|
kill $MOCK
|
||||||
|
sed 's/[0-9.]\+/N/g' /tmp/ui-signals-t1.log | diff .superpowers/sdd/ui-signals-baseline-udps.txt - ; grep -c Gtk-CRITICAL /tmp/ui-signals-t1.log; grep 'gtk warnings' /tmp/ui-signals-t1.log
|
||||||
|
```
|
||||||
|
Then run `UDPSCOPE_PROBE_SLIDEOVER_MS=1500`, `UDPSCOPE_PROBE_BADGES_MS=1500` and `UDPSCOPE_PROBE_TOUR_SURFACE_MS=<as in verification.md>` the same way. Expected: zero build warnings; the slide-over and badge probes read as in step 1 (`table_bad=0`, `open_marked=1`, `closed_unmarked=1`); the tour-surface probe shows the `signals` step now opens the `signals` page and `tour_plan_holds(signal_list)=1`. **Allowed baseline changes:** the dock's size/position lines that included the list (re-pointed per Step 7), any `signal_panel` geometry line. Explain each in the report.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add -A src docs
|
||||||
|
git commit -m "ui: the signal list is the signals slide-over page, opened by a + badge or g"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Per-signal pages, `assign_signal_to_row()` and `reset_signal_color()`
|
||||||
|
|
||||||
|
Builds `signal:<name>` pages (nothing opens them yet except activating a list row) and extracts the two actions the page shares with the list.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ui_signal_page.cpp`
|
||||||
|
- Modify: `src/ui_signals.cpp`, `src/ui_slideover.cpp`, `src/ui_app.hpp`, `src/ui.cpp` (probe env var), `src/ui_probe.cpp` or `src/ui_signal_page.cpp` (probe), `CMakeLists.txt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (declare in `ui_app.hpp`, namespace `udpscope::ui_impl`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// Every plottable signal once (the time base skipped, a group's layers folded to the first), in model order.
|
||||||
|
/// The one rule the list and the badges share.
|
||||||
|
std::vector<std::size_t> listed_signals(const std::vector<SignalInfo>& signals);
|
||||||
|
/// Puts `signal` (an index into `signals()`) on plot `row` in the first free slot, switching it on first if
|
||||||
|
/// need be. Says why not through `set_notice` and returns false when it cannot. The one path a drop and a
|
||||||
|
/// signal page's "Plot on" button share.
|
||||||
|
bool assign_signal_to_row(App& app, std::size_t signal, int row);
|
||||||
|
/// Back to the palette colour for the signal's position.
|
||||||
|
void reset_signal_color(App& app, std::size_t signal);
|
||||||
|
/// Holds `widget` alive across the current emission (was `static` in ui_signals.cpp).
|
||||||
|
void outlive_the_rebuild(GtkWidget* widget);
|
||||||
|
bool slideover_remove_page(App& app, const std::string& id); // ui_slideover.cpp
|
||||||
|
/// Creates the page `signal:<name>` if it is missing, fills it, and returns whether it exists afterwards.
|
||||||
|
bool signal_page_ensure(App& app, const std::string& name);
|
||||||
|
/// Called at the end of `rebuild_signal_panels`: refills every existing signal page, drops the pages of
|
||||||
|
/// signals the model no longer has (closing the slide-over first if one of them is open).
|
||||||
|
void signal_pages_sync(App& app);
|
||||||
|
```
|
||||||
|
- `App` gains `std::map<std::string, GtkWidget*> signal_pages;` (name -> the page's content box).
|
||||||
|
|
||||||
|
- [ ] **Step 1: `listed_signals()`**
|
||||||
|
|
||||||
|
In `src/ui_signals.cpp`, add (before `rebuild_signal_list`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::vector<std::size_t> listed_signals(const std::vector<SignalInfo>& signals) {
|
||||||
|
std::vector<std::size_t> out;
|
||||||
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
||||||
|
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable
|
||||||
|
const std::string base = layer_base_name(signals[i].name);
|
||||||
|
bool seen = false;
|
||||||
|
for (std::size_t j = 0; j < i && !seen; ++j) {
|
||||||
|
seen = !ScopeModel::is_time_signal(signals, j) && layer_base_name(signals[j].name) == base;
|
||||||
|
}
|
||||||
|
if (!seen) out.push_back(i);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
and make `rebuild_signal_list` use it:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::vector<std::size_t> rows;
|
||||||
|
for (std::size_t i : listed_signals(signals)) {
|
||||||
|
// Locked: a row per signal that is *on* ... (keep the existing comment)
|
||||||
|
if (app.signals_locked && !app.model->signal_enabled(i)) continue;
|
||||||
|
rows.push_back(i);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Behaviour is unchanged (the dump's `rows == signals + 1 + math` line must not move).
|
||||||
|
|
||||||
|
- [ ] **Step 2: `assign_signal_to_row()`**
|
||||||
|
|
||||||
|
Move lines `on_drop_channel` from `if (signal >= app->model->signals().size()) return FALSE;` through `notice("plotting '" ...)` into the new function, with `app->` -> `app.`; keep the `notice` lambda, the "enable it, then take the channel that appears" branch, the free-slot search and the two refusal messages verbatim, `return false`/`return true`. `on_drop_channel` becomes:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
gboolean on_drop_channel(GtkDropTarget*, const GValue* value, double x, double y, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
if (!G_VALUE_HOLDS_STRING(value)) return FALSE;
|
||||||
|
const int row = row_at(*app, x, y); // where the drop landed decides the row: one area covers them all
|
||||||
|
const char* text = g_value_get_string(value);
|
||||||
|
const std::size_t signal = static_cast<std::size_t>(std::atoll(text ? text : "0"));
|
||||||
|
if (!assign_signal_to_row(*app, signal, row)) return FALSE;
|
||||||
|
cancel_drag_on(*app, row);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`rebuild_signal_panels(app)` stays inside `assign_signal_to_row` (before the notice), as it is today.
|
||||||
|
|
||||||
|
- [ ] **Step 3: `reset_signal_color()`**
|
||||||
|
|
||||||
|
Extract the body of the `recolor` lambda (`ui_signals.cpp` ~386-401) into:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void reset_signal_color(App& app, std::size_t signal) {
|
||||||
|
std::string name;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||||
|
const std::vector<SignalInfo>& sigs = app.model->signals();
|
||||||
|
if (signal >= sigs.size()) return;
|
||||||
|
name = sigs[signal].name;
|
||||||
|
}
|
||||||
|
app.rows->clear_signal_color(name);
|
||||||
|
app.relayout_pending = true;
|
||||||
|
rebuild_signal_panels(app);
|
||||||
|
schedule_layout_save(app);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
The lambda keeps `outlive_the_rebuild(GTK_WIDGET(b))` first (the list rebuild destroys it) and then calls this. Remove `static` from `outlive_the_rebuild` and move its definition above `rebuild_signal_list` users unchanged.
|
||||||
|
|
||||||
|
- [ ] **Step 4: `slideover_remove_page()` (`src/ui_slideover.cpp`)**
|
||||||
|
|
||||||
|
Read how `slideover_add_page` records a page (stack child name, any title/page registry on `SlideOver`). Implement the inverse: returns false if the id is unknown; refuses (returns false) if it is the open page; otherwise removes the stack child (`gtk_stack_remove`) and erases whatever registry entry `slideover_add_page` made.
|
||||||
|
|
||||||
|
- [ ] **Step 5: `src/ui_signal_page.cpp`**
|
||||||
|
|
||||||
|
Create the file with the SPDX header, `#include "ui_app.hpp"`, `<gtk/gtk.h>`, `<string>`, namespaces `udpscope::ui_impl`. Page ids are `"signal:" + name`. Contents (an anonymous namespace plus the two public functions):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string page_id(const std::string& name) { return "signal:" + name; }
|
||||||
|
|
||||||
|
/// The index of `name` in the model's signals, or npos.
|
||||||
|
std::size_t signal_index(App& app, const std::string& name) {
|
||||||
|
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||||
|
const std::vector<SignalInfo>& s = app.model->signals();
|
||||||
|
for (std::size_t i = 0; i < s.size(); ++i) {
|
||||||
|
if (s[i].name == name) return i;
|
||||||
|
}
|
||||||
|
return std::string::npos;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* text_button(const char* label, GtkWidget* parent) {
|
||||||
|
GtkWidget* b = gtk_button_new_with_label(label);
|
||||||
|
gtk_box_append(GTK_BOX(parent), b);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores the signal's index and the page's name on the button, so a handler resolves identity the way the
|
||||||
|
/// list does.
|
||||||
|
void tag(GtkWidget* b, std::size_t index) {
|
||||||
|
g_object_set_data(G_OBJECT(b), "udpscope-signal", GSIZE_TO_POINTER(index));
|
||||||
|
}
|
||||||
|
std::size_t tagged(GtkButton* b) {
|
||||||
|
return static_cast<std::size_t>(GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(b), "udpscope-signal")));
|
||||||
|
}
|
||||||
|
|
||||||
|
void fill(App& app, const std::string& name, GtkWidget* page) {
|
||||||
|
for (GtkWidget* c = gtk_widget_get_first_child(page); c != nullptr;) {
|
||||||
|
GtkWidget* next = gtk_widget_get_next_sibling(c);
|
||||||
|
gtk_box_remove(GTK_BOX(page), c);
|
||||||
|
c = next;
|
||||||
|
}
|
||||||
|
const std::size_t index = signal_index(app, name);
|
||||||
|
if (index == std::string::npos) return;
|
||||||
|
// A snapshot, taken under the lock: the chip helper wants the whole vector, and the producer replaces it
|
||||||
|
// on every CONFIG.
|
||||||
|
std::vector<SignalInfo> all;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||||
|
all = app.model->signals();
|
||||||
|
}
|
||||||
|
if (index >= all.size()) return;
|
||||||
|
const SignalInfo sig = all[index];
|
||||||
|
const bool enabled = app.model->signal_enabled(index);
|
||||||
|
const bool plotted = signal_plotted(app, sig.name);
|
||||||
|
const bool unlocked = !app.signals_locked;
|
||||||
|
|
||||||
|
// Back to the list: a page reached from a list row has no other way home.
|
||||||
|
GtkWidget* back = text_button("All signals", page);
|
||||||
|
gtk_widget_set_halign(back, GTK_ALIGN_START);
|
||||||
|
gtk_widget_add_css_class(back, "flat");
|
||||||
|
g_signal_connect(back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
|
slideover_open(*static_cast<App*>(user), "signals");
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
|
||||||
|
GtkWidget* title = gtk_label_new(sig.name.c_str());
|
||||||
|
gtk_widget_add_css_class(title, "title-3");
|
||||||
|
gtk_widget_set_halign(title, GTK_ALIGN_START);
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(title), PANGO_ELLIPSIZE_END);
|
||||||
|
gtk_box_append(GTK_BOX(page), title);
|
||||||
|
if (!sig.unit.empty()) {
|
||||||
|
GtkWidget* unit = gtk_label_new(sig.unit.c_str());
|
||||||
|
gtk_widget_add_css_class(unit, "dim-label");
|
||||||
|
gtk_widget_set_halign(unit, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(page), unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire / compute. The same handler as the list's tick, which reads the signal index off the widget.
|
||||||
|
GtkWidget* enable = gtk_check_button_new_with_label(sig.math ? "Compute this channel" : "Acquire this signal");
|
||||||
|
app.updating_panels = true;
|
||||||
|
gtk_check_button_set_active(GTK_CHECK_BUTTON(enable), enabled);
|
||||||
|
app.updating_panels = false;
|
||||||
|
g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(index));
|
||||||
|
g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app);
|
||||||
|
gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty());
|
||||||
|
gtk_box_append(GTK_BOX(page), enable);
|
||||||
|
|
||||||
|
// Colour.
|
||||||
|
GtkWidget* colour_head = gtk_label_new("Colour");
|
||||||
|
gtk_widget_add_css_class(colour_head, "heading");
|
||||||
|
gtk_widget_set_halign(colour_head, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(page), colour_head);
|
||||||
|
GtkWidget* chip = gtk_label_new(nullptr);
|
||||||
|
gtk_label_set_markup(GTK_LABEL(chip), signal_chip_markup(app, all, index).c_str());
|
||||||
|
GtkWidget* colour_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
|
||||||
|
gtk_box_append(GTK_BOX(colour_row), chip);
|
||||||
|
GtkWidget* pick = text_button("Change colour...", colour_row);
|
||||||
|
gtk_widget_set_sensitive(pick, plotted && unlocked);
|
||||||
|
gtk_widget_set_tooltip_text(pick, plotted ? "the colour this signal is plotted in"
|
||||||
|
: "on no plot, so there is no trace to colour");
|
||||||
|
tag(pick, index);
|
||||||
|
g_signal_connect(pick, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
|
||||||
|
open_signal_color(*static_cast<App*>(user), tagged(b));
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
GtkWidget* reset = text_button("Default colour", colour_row);
|
||||||
|
gtk_widget_set_sensitive(reset, unlocked && app.rows->signal_colors().count(sig.name) != 0);
|
||||||
|
tag(reset, index);
|
||||||
|
g_signal_connect(reset, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
|
||||||
|
outlive_the_rebuild(GTK_WIDGET(b));
|
||||||
|
reset_signal_color(*static_cast<App*>(user), tagged(b));
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
gtk_box_append(GTK_BOX(page), colour_row);
|
||||||
|
|
||||||
|
// Row: "Plot on 1 2 3 ..." and the way back off.
|
||||||
|
GtkWidget* row_head = gtk_label_new("Plot on");
|
||||||
|
gtk_widget_add_css_class(row_head, "heading");
|
||||||
|
gtk_widget_set_halign(row_head, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(page), row_head);
|
||||||
|
GtkWidget* rows_box = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
|
||||||
|
for (int r = 0; r < app.rows->row_count(); ++r) {
|
||||||
|
GtkWidget* b = text_button(std::to_string(r + 1).c_str(), rows_box);
|
||||||
|
tag(b, index);
|
||||||
|
g_object_set_data(G_OBJECT(b), "udpscope-row", GINT_TO_POINTER(r));
|
||||||
|
gtk_widget_set_tooltip_text(b, "draw this signal on this plot");
|
||||||
|
g_signal_connect(b, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
|
||||||
|
outlive_the_rebuild(GTK_WIDGET(b));
|
||||||
|
assign_signal_to_row(*static_cast<App*>(user), tagged(b),
|
||||||
|
GPOINTER_TO_INT(g_object_get_data(G_OBJECT(b), "udpscope-row")));
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
}
|
||||||
|
gtk_box_append(GTK_BOX(page), rows_box);
|
||||||
|
GtkWidget* off = text_button("Remove from plots", page);
|
||||||
|
gtk_widget_set_halign(off, GTK_ALIGN_START);
|
||||||
|
gtk_widget_set_sensitive(off, plotted);
|
||||||
|
tag(off, index);
|
||||||
|
g_signal_connect(off, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
outlive_the_rebuild(GTK_WIDGET(b));
|
||||||
|
std::string n;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||||
|
const std::vector<SignalInfo>& s = a->model->signals();
|
||||||
|
if (tagged(b) >= s.size()) return;
|
||||||
|
n = s[tagged(b)].name;
|
||||||
|
}
|
||||||
|
const int gone = remove_signal_from_plots(*a, n);
|
||||||
|
a->relayout_pending = true;
|
||||||
|
rebuild_signal_panels(*a);
|
||||||
|
schedule_layout_save(*a);
|
||||||
|
set_notice(*a, "'" + n + "' left " + std::to_string(gone) + (gone == 1 ? " plot" : " plots"), 4);
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
|
||||||
|
// Information (a streamed signal's), calibration (ditto), and a math channel's editor.
|
||||||
|
GtkWidget* more_head = gtk_label_new(sig.math ? "Math channel" : "Signal");
|
||||||
|
gtk_widget_add_css_class(more_head, "heading");
|
||||||
|
gtk_widget_set_halign(more_head, GTK_ALIGN_START);
|
||||||
|
gtk_box_append(GTK_BOX(page), more_head);
|
||||||
|
GtkWidget* more = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||||
|
if (sig.math) {
|
||||||
|
GtkWidget* edit = text_button("Edit graph...", more);
|
||||||
|
tag(edit, index);
|
||||||
|
g_signal_connect(edit, "clicked", G_CALLBACK(on_math_edit_clicked), &app);
|
||||||
|
GtkWidget* del = text_button("Delete", more);
|
||||||
|
gtk_widget_add_css_class(del, "destructive-action");
|
||||||
|
tag(del, index);
|
||||||
|
g_signal_connect(del, "clicked", G_CALLBACK(on_math_delete_clicked), &app);
|
||||||
|
} else {
|
||||||
|
GtkWidget* info = text_button("Information", more);
|
||||||
|
tag(info, index);
|
||||||
|
g_signal_connect(info, "clicked", G_CALLBACK(on_info_clicked), &app);
|
||||||
|
GtkWidget* cal = text_button("Calibrate...", more);
|
||||||
|
gtk_widget_set_sensitive(cal, unlocked);
|
||||||
|
gtk_widget_set_tooltip_text(cal, "gain and offset, applied at ingest");
|
||||||
|
tag(cal, index);
|
||||||
|
g_signal_connect(cal, "clicked", G_CALLBACK(+[](GtkButton* b, gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
open_calibration(*a, GTK_WINDOW(a->window), tagged(b));
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
}
|
||||||
|
gtk_box_append(GTK_BOX(page), more);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
```
|
||||||
|
`on_math_edit_clicked`, `on_math_delete_clicked`, `on_info_clicked`, `open_calibration`, `open_signal_color`, `signal_plotted` and `signal_chip_markup` are already declared in `ui_app.hpp` (check; if `on_math_edit_clicked` / `on_math_delete_clicked` are not, add the declarations). `on_math_edit_clicked` calls `dismiss_row_menu(button)`, which looks for a `GtkPopover` ancestor and does nothing when there is none -- confirm that by reading it. `on_math_delete_clicked` already calls `outlive_the_rebuild`. Then the two public functions:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
bool signal_page_ensure(App& app, const std::string& name) {
|
||||||
|
if (signal_index(app, name) == std::string::npos) return false;
|
||||||
|
auto it = app.signal_pages.find(name);
|
||||||
|
if (it == app.signal_pages.end()) {
|
||||||
|
GtkWidget* page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||||
|
gtk_widget_set_margin_top(page, 12);
|
||||||
|
gtk_widget_set_margin_bottom(page, 12);
|
||||||
|
gtk_widget_set_margin_start(page, 12);
|
||||||
|
gtk_widget_set_margin_end(page, 12);
|
||||||
|
if (!slideover_add_page(app, page_id(name), name, page)) return false;
|
||||||
|
it = app.signal_pages.emplace(name, page).first;
|
||||||
|
}
|
||||||
|
fill(app, name, it->second);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void signal_pages_sync(App& app) {
|
||||||
|
for (auto it = app.signal_pages.begin(); it != app.signal_pages.end();) {
|
||||||
|
if (signal_index(app, it->first) == std::string::npos) {
|
||||||
|
if (slideover_page(app) == page_id(it->first)) slideover_close(app);
|
||||||
|
slideover_remove_page(app, page_id(it->first));
|
||||||
|
it = app.signal_pages.erase(it);
|
||||||
|
} else {
|
||||||
|
fill(app, it->first, it->second);
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`slideover_add_page` wraps `content` in a scroller and returns it through the stack, so `page` stays the box `fill` clears. Check that the gtk widget `page` is not destroyed by `slideover_remove_page` before `erase` (it is not used afterwards). Call `signal_pages_sync(app)` at the end of `rebuild_signal_panels` (before `app.signal_generation = ...`). `signal_index` takes the model mutex, so `signal_pages_sync` must not be called with it held (`rebuild_signal_panels` releases it before the list rebuild).
|
||||||
|
|
||||||
|
- [ ] **Step 6: A list row opens its page**
|
||||||
|
|
||||||
|
In `rebuild_signal_list`, tag each row: `g_object_set_data(G_OBJECT(row), "udpscope-signal", GSIZE_TO_POINTER(i));` (the math divider row has none and is not activatable). In `build_signal_panel`, after the null check:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
g_signal_connect(app.signal_list, "row-activated",
|
||||||
|
G_CALLBACK(+[](GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
const std::size_t index = static_cast<std::size_t>(
|
||||||
|
GPOINTER_TO_SIZE(g_object_get_data(G_OBJECT(row), "udpscope-signal")));
|
||||||
|
std::string name;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(a->model->mutex());
|
||||||
|
const std::vector<SignalInfo>& s = a->model->signals();
|
||||||
|
if (index >= s.size()) return;
|
||||||
|
name = s[index].name;
|
||||||
|
}
|
||||||
|
if (signal_page_ensure(*a, name)) slideover_open(*a, "signal:" + name);
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
```
|
||||||
|
If `signals-panel.ui`'s list box has `activate-on-single-click` off or selection mode that swallows activation, set `activate-on-single-click` to true in code. Read the file; the row's own check button / colour-chip gesture / menu button must still work (they consume their own clicks).
|
||||||
|
|
||||||
|
- [ ] **Step 7: Probe `UDPSCOPE_PROBE_SIGNAL_PAGES_MS`**
|
||||||
|
|
||||||
|
Declare `gboolean signal_page_probe(gpointer)` (put it in `ui_signal_page.cpp`), schedule it in `ui.cpp` next to the badges probe. It takes the first non-math listed signal (from `listed_signals`), calls `signal_page_ensure`, `slideover_open("signal:<name>")` and prints:
|
||||||
|
|
||||||
|
```
|
||||||
|
[signalpage] name=<n> open=<slideover_page==id> pages=<signal_pages.size()> enable_visible=<0|1> plot_buttons=<count of the "Plot on" buttons> plot_on_row0=<1 if assign_signal_to_row(...,0) returned true and signal_plotted> removed=<remove button click leaves signal_plotted false> back_to_list=<slideover_open("signals") -> page "signals"> pruned=<1 after the page's signal is gone>
|
||||||
|
```
|
||||||
|
To check `pruned` without a CONFIG, call `slideover_close`, `slideover_remove_page` directly is *not* a test of `signal_pages_sync`; instead leave `pruned=-1` when it cannot be exercised, and exercise `signal_pages_sync` by the ordinary reconfiguration paths (adding then deleting a math channel with `add_math_channel` / `a->math.erase(...)`+`apply_math_channels` if the probe harness already does that elsewhere; otherwise `-1`). Close the slide-over at the end.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Verify**
|
||||||
|
|
||||||
|
Build (zero warnings), then the UDPS baseline run as in Task 1 Step 8 diffed against `.superpowers/sdd/ui-signals-baseline-udps.txt` (expected: the Task 1 differences only), then:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$!
|
||||||
|
sleep 1.5
|
||||||
|
xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_PAGES_MS=1500 \
|
||||||
|
./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | grep -E '\[signalpage\]|\[udpscope\] drop|Gtk-|gtk warnings'
|
||||||
|
kill $MOCK
|
||||||
|
```
|
||||||
|
Expected: `open=1 plot_on_row0=1 removed=1 back_to_list=1`, `plot_buttons` equal to the figure's row count, `gtk warnings: 0`, no `Gtk-CRITICAL`. The existing drop probes (`[drop]` lines in the baseline) must read unchanged: they now run through `assign_signal_to_row`.
|
||||||
|
|
||||||
|
- [ ] **Step 9: Commit**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add -A src CMakeLists.txt docs
|
||||||
|
git commit -m "ui: per-signal slide-over pages; the drop's assignment and the colour reset as shared functions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: The signal badges, the "N signals" overflow and the order
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui_signals.cpp` (call), `src/ui.cpp` (probe env var), `src/ui_widgets.cpp` (CSS, if any)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `listed_signals`, `signal_page_ensure`, `signal_chip_markup`, `signal_pages_sync` (Task 2).
|
||||||
|
- Produces:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// ui_app.hpp
|
||||||
|
struct Badge { /* existing */ std::string signal_name; std::size_t signal = 0; }; // the two new fields: empty/0 for non-signal badges
|
||||||
|
struct BadgeBar {
|
||||||
|
/* existing */
|
||||||
|
GtkWidget* signals_row = nullptr; ///< the box inside the scroller: signal badges, then "+"
|
||||||
|
std::vector<std::unique_ptr<Badge>> signal_badges; ///< one per shown enabled signal; empty when overflowed
|
||||||
|
Badge overflow; ///< the "N signals" badge, used only beyond kMaxSignalBadges
|
||||||
|
Badge plus; ///< (Task 1)
|
||||||
|
};
|
||||||
|
inline constexpr std::size_t kMaxSignalBadges = 6;
|
||||||
|
// App: std::vector<std::string> badge_order; ///< names, in the order the user left the badges (memory only)
|
||||||
|
void sync_signal_badges(App& app); ///< rebuilds the signal badges from the model; ui_badges.cpp
|
||||||
|
gboolean signal_badges_probe(gpointer user);
|
||||||
|
```
|
||||||
|
Include `<memory>` in `ui_app.hpp` if it is not already.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Put the signals in a scroller (`build_badge_bar`)**
|
||||||
|
|
||||||
|
Before creating any badge:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// The signal badges sit in a scroller: a bar whose minimum width grew with the number of signals would
|
||||||
|
// widen the window (layout must not depend on content). EXTERNAL scrolls by wheel/touch with no bar.
|
||||||
|
GtkWidget* scroller = gtk_scrolled_window_new();
|
||||||
|
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_EXTERNAL, GTK_POLICY_NEVER);
|
||||||
|
gtk_widget_set_hexpand(scroller, TRUE);
|
||||||
|
bb.signals_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||||
|
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), bb.signals_row);
|
||||||
|
gtk_box_append(GTK_BOX(bb.signals_slot), scroller);
|
||||||
|
```
|
||||||
|
and change the `+` badge to `make_badge(app, bb.plus, bb.signals_row, "+", "signals")` (it follows the signal badges because they are inserted before it; see Step 3). Move `make_badge`'s parent argument usage accordingly.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `make_signal_badge`**
|
||||||
|
|
||||||
|
In the anonymous namespace of `ui_badges.cpp`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// A signal's badge: its colour dot (grey when it has no trace) and its name. Words and a dot, no icon.
|
||||||
|
void make_signal_badge(App& app, Badge& b, const std::vector<SignalInfo>& signals, std::size_t index) {
|
||||||
|
b.signal = index;
|
||||||
|
b.signal_name = signals[index].name;
|
||||||
|
b.page = "signal:" + b.signal_name;
|
||||||
|
b.button = gtk_button_new();
|
||||||
|
gtk_widget_add_css_class(b.button, "flat");
|
||||||
|
gtk_widget_add_css_class(b.button, "udpscope-badge");
|
||||||
|
GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||||
|
GtkWidget* dot = gtk_label_new(nullptr);
|
||||||
|
gtk_label_set_markup(GTK_LABEL(dot), signal_chip_markup(app, signals, index).c_str());
|
||||||
|
b.name = gtk_label_new(b.signal_name.c_str());
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(b.name), PANGO_ELLIPSIZE_END);
|
||||||
|
gtk_label_set_max_width_chars(GTK_LABEL(b.name), 14);
|
||||||
|
gtk_box_append(GTK_BOX(row), dot);
|
||||||
|
gtk_box_append(GTK_BOX(row), b.name);
|
||||||
|
gtk_button_set_child(GTK_BUTTON(b.button), row);
|
||||||
|
gtk_widget_set_tooltip_text(b.button, b.signal_name.c_str());
|
||||||
|
g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app);
|
||||||
|
g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`b.state` stays `nullptr`; `sync_badge_bar` never touches it for these. In `on_badge_clicked`, before `slideover_toggle(*app, b->page)`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// A signal's page is made on first use (and kept in step by `signal_pages_sync`).
|
||||||
|
if (b->page.rfind("signal:", 0) == 0 && !signal_page_ensure(*app, b->signal_name)) return;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: `sync_signal_badges`**
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
void sync_signal_badges(App& app) {
|
||||||
|
BadgeBar& bb = app.badges;
|
||||||
|
if (bb.signals_row == nullptr) return;
|
||||||
|
std::vector<SignalInfo> signals;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||||
|
signals = app.model->signals();
|
||||||
|
}
|
||||||
|
// The enabled signals, in the user's order: names in `badge_order` first, the rest in model order.
|
||||||
|
std::vector<std::size_t> shown;
|
||||||
|
for (std::size_t i : listed_signals(signals)) {
|
||||||
|
if (app.model->signal_enabled(i)) shown.push_back(i);
|
||||||
|
}
|
||||||
|
const auto rank = [&](std::size_t i) {
|
||||||
|
const auto it = std::find(app.badge_order.begin(), app.badge_order.end(), signals[i].name);
|
||||||
|
return static_cast<std::size_t>(it - app.badge_order.begin()); // == size() when unknown: sorts last
|
||||||
|
};
|
||||||
|
std::stable_sort(shown.begin(), shown.end(), [&](std::size_t a, std::size_t b) { return rank(a) < rank(b); });
|
||||||
|
|
||||||
|
// Clear: every signal badge, and the overflow if it was there. The "+" is kept.
|
||||||
|
for (GtkWidget* c = gtk_widget_get_first_child(bb.signals_row); c != nullptr;) {
|
||||||
|
GtkWidget* next = gtk_widget_get_next_sibling(c);
|
||||||
|
if (c != bb.plus.button) gtk_box_remove(GTK_BOX(bb.signals_row), c);
|
||||||
|
c = next;
|
||||||
|
}
|
||||||
|
bb.signal_badges.clear();
|
||||||
|
bb.overflow = Badge{};
|
||||||
|
|
||||||
|
const bool overflowed = shown.size() > kMaxSignalBadges;
|
||||||
|
if (overflowed) {
|
||||||
|
make_badge(app, bb.overflow, bb.signals_row, "", "signals");
|
||||||
|
gtk_label_set_text(GTK_LABEL(bb.overflow.name), (std::to_string(shown.size()) + " signals").c_str());
|
||||||
|
} else {
|
||||||
|
for (std::size_t i : shown) {
|
||||||
|
bb.signal_badges.push_back(std::make_unique<Badge>());
|
||||||
|
make_signal_badge(app, *bb.signal_badges.back(), signals, i);
|
||||||
|
gtk_box_insert_child_after(GTK_BOX(bb.signals_row), bb.signal_badges.back()->button,
|
||||||
|
gtk_widget_get_prev_sibling(bb.plus.button));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The overflow badge stands for the "+" (it opens the same page); the "+" is shown only when it is not.
|
||||||
|
gtk_widget_set_visible(bb.plus.button, !overflowed);
|
||||||
|
sync_badge_bar(app);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`make_badge` appends to its parent; for the overflow badge it lands after the `+`, which is hidden then, so the order is immaterial. `make_badge(..., "", ...)` with an empty name: fine, the text is set right after. (`Badge{}` assignment: `Badge` holds only pointers/strings so this is a plain reset; the overflow button was destroyed by the clear loop.) The "+" being hidden when overflowed is the one deliberate use of hiding: the overflow badge replaces it. In `sync_badge_bar` add, for each signal badge and the overflow, `set_open(button, slideover_page(app) == badge.page)` (the overflow's page is `"signals"`, so it shares the `+`'s rule: both read the same state, only one is visible).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Rebuild when the model's structure or a plot assignment changes**
|
||||||
|
|
||||||
|
At the end of `rebuild_signal_panels` (after `signal_pages_sync(app)` from Task 2) add `sync_signal_badges(app);`. Also call it once at the end of `build_badge_bar` (after the `+` is created) so the first frame is right. Also add `std::vector<std::string> badge_order;` and `#include <algorithm>` / `<memory>` where needed. Every event that changes a dot (assign, remove, colour, enable) already calls `rebuild_signal_panels`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Probe `UDPSCOPE_PROBE_SIGNAL_BADGES_MS`**
|
||||||
|
|
||||||
|
Schedule `signal_badges_probe` in `ui.cpp` (like the badges probe). It prints one line:
|
||||||
|
|
||||||
|
```
|
||||||
|
[sigbadges] enabled=<shown count> badges=<signal_badges.size()> overflow=<0|1> overflow_text=<text|-> plus_visible=<0|1> dot_plotted=<number of badges whose dot is not the unplotted grey; compute by signal_plotted> bar_h=<gtk_widget_get_height(bar)> bar_h_fixed=<1 if bar_h before == after toggling one signal off and on> bar_min_w=<gtk_widget_measure min width of badge_bar> opens=<1 if emitting "clicked" on badges[0] makes slideover_page == signal:<name>, and a second emit closes it> plus_opens=<1 if the same for plus (or the overflow when overflowed)>
|
||||||
|
```
|
||||||
|
Toggle a signal with `app.model->set_signal_enabled(i, false)` then `rebuild_signal_panels(app)`, and back. `bar_min_w` is only a record: the check is that it is the same with the mock's N signals as `bar_min_w` printed by the E-1608 run in Task 5 (stated there).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Verify**
|
||||||
|
|
||||||
|
Build (zero warnings). UDPS run with `UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500` against the mock: expected `badges == enabled` (or `overflow=1` if the mock declares more than 6), `bar_h=36` or the bar's measured value with `bar_h_fixed=1`, `opens=1 plus_opens=1`, `gtk warnings: 0`. Then the Task 1 baseline diff and the slide-over/badges/tour-surface probes: **allowed baseline changes:** none beyond Tasks 1-2. A screenshot at 1400x900 (`GSK_RENDERER=cairo`, `import -window root`) to look at the bar once.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add -A src docs
|
||||||
|
git commit -m "ui: one badge per enabled signal, an N signals overflow, and the order kept in memory"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Badge drag (to a plot row, and to reorder); the list stops being a drag source
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_badges.cpp`, `src/ui_signals.cpp`, `src/ui.cpp`, `src/ui_app.hpp`, `src/ui_probe.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `on_drag_prepare(GtkDragSource*, double, double, gpointer)` (payload: the signal index as a decimal string), `DragContext{App*, size_t}`, `on_drop_channel` (the plot area's drop target, unchanged), `badge_order`, `signal_badges`.
|
||||||
|
- Produces: nothing new in `ui_app.hpp` except removals.
|
||||||
|
|
||||||
|
- [ ] **Step 1: The badge is a drag source and a reorder target (`make_signal_badge`)**
|
||||||
|
|
||||||
|
Append to `make_signal_badge`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// A drag handle: the same payload the list's rows used to carry, so the plot area's drop target takes it
|
||||||
|
// unchanged. The slide-over steps aside for the drag, since it covers the plot's right edge.
|
||||||
|
DragContext* ctx = new DragContext{&app, index};
|
||||||
|
GtkDragSource* source = gtk_drag_source_new();
|
||||||
|
gtk_drag_source_set_actions(source, GDK_ACTION_COPY);
|
||||||
|
g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx,
|
||||||
|
[](gpointer data, GClosure*) { delete static_cast<DragContext*>(data); },
|
||||||
|
G_CONNECT_DEFAULT);
|
||||||
|
g_signal_connect(source, "drag-begin", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gpointer user) {
|
||||||
|
slideover_close(*static_cast<App*>(user));
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(source));
|
||||||
|
|
||||||
|
// Dropping another signal's badge on this one moves it in front of this one.
|
||||||
|
GtkDropTarget* drop = gtk_drop_target_new(G_TYPE_STRING, GDK_ACTION_COPY);
|
||||||
|
g_signal_connect(drop, "drop", G_CALLBACK(on_badge_drop), &b);
|
||||||
|
gtk_widget_add_controller(b.button, GTK_EVENT_CONTROLLER(drop));
|
||||||
|
```
|
||||||
|
and, above it in the anonymous namespace:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// The reorder: widgets are moved, never rebuilt, so neither the dragged badge nor this target (whose "drop"
|
||||||
|
/// emission is running) is destroyed under the drag.
|
||||||
|
gboolean on_badge_drop(GtkDropTarget*, const GValue* value, double, double, gpointer user) {
|
||||||
|
Badge* target = static_cast<Badge*>(user);
|
||||||
|
App* app = static_cast<App*>(g_object_get_data(G_OBJECT(target->button), "udpscope-app"));
|
||||||
|
if (app == nullptr || !G_VALUE_HOLDS_STRING(value)) return FALSE;
|
||||||
|
const char* text = g_value_get_string(value);
|
||||||
|
const std::size_t signal = static_cast<std::size_t>(std::atoll(text ? text : "0"));
|
||||||
|
Badge* moved = nullptr;
|
||||||
|
for (auto& b : app->badges.signal_badges) {
|
||||||
|
if (b->signal == signal) moved = b.get();
|
||||||
|
}
|
||||||
|
if (moved == nullptr) return FALSE;
|
||||||
|
if (moved == target) return TRUE;
|
||||||
|
GtkWidget* prev = gtk_widget_get_prev_sibling(target->button);
|
||||||
|
if (prev != moved->button) { // already directly in front: nothing to move
|
||||||
|
gtk_box_reorder_child_after(GTK_BOX(app->badges.signals_row), moved->button, prev);
|
||||||
|
}
|
||||||
|
// The order, as the widgets now stand.
|
||||||
|
app->badge_order.clear();
|
||||||
|
for (GtkWidget* c = gtk_widget_get_first_child(app->badges.signals_row); c != nullptr;
|
||||||
|
c = gtk_widget_get_next_sibling(c)) {
|
||||||
|
for (auto& b : app->badges.signal_badges) {
|
||||||
|
if (b->button == c) app->badge_order.push_back(b->signal_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
`gtk_box_reorder_child_after(box, child, nullptr)` puts the child first, which is what a `nullptr` `prev` means here.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Remove the list's drag source (`rebuild_signal_list`)**
|
||||||
|
|
||||||
|
Delete the `DragContext* ctx ...` block through `gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source));` (~454-471). `grep -n row_drag_sidebar src` : if `rebuild_signal_list` was its only caller, delete `row_drag_sidebar`, its declaration and `App::sidebar_hidden_for_drag`; if something else calls it, leave it. Update the comment above the list's row loop that mentions dragging, if any.
|
||||||
|
|
||||||
|
- [ ] **Step 3: The probes that drove the list's drag**
|
||||||
|
|
||||||
|
`src/ui_probe.cpp` ~3843-3847 walks `signal_list` rows (read what it does: if it performs a drag or a drop by index, it now takes the index from `signal_badges[0]->signal` and calls `assign_signal_to_row` / `on_drop_channel` with the same payload). Keep every printed line it printed before.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Extend `signal_badges_probe`**
|
||||||
|
|
||||||
|
After the existing line print a second:
|
||||||
|
|
||||||
|
```
|
||||||
|
[sigbadges] drag_sources=<number of badges with a GtkDragSource controller> drop_targets=<... GtkDropTarget> prepare_ok=<1 if on_drag_prepare for badges[0]'s DragContext returns a non-null provider> reorder_before=<names comma-separated> reorder_after=<...> reorder_ok=<1 if dropping the last badge on the first moved it to the front and badge_order matches the widget order> payload_drop=<1 if on_drop_channel-equivalent: assign_signal_to_row(badges[0]->signal, 0) leaves it plotted> list_drag=<number of GtkDragSource controllers on the first list row; must be 0>
|
||||||
|
```
|
||||||
|
Controllers are enumerated with `gtk_widget_observe_controllers()` (a `GListModel`) and `G_TYPE_CHECK_INSTANCE_TYPE`/`GTK_IS_DRAG_SOURCE`. With fewer than 2 badges print `reorder_ok=-1`. Drop the reordered badge back into its place at the end so later probes see the original order. For the `GValue`: `GValue v = G_VALUE_INIT; g_value_init(&v, G_TYPE_STRING); g_value_set_string(&v, std::to_string(signal).c_str());` and call `on_badge_drop` directly -- it is in the anonymous namespace of `ui_badges.cpp` where the probe lives, so no export is needed.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Verify**
|
||||||
|
|
||||||
|
Build (zero warnings; `-Wunused` would flag a leftover `row_drag_sidebar`). UDPS run with `UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=1500`. Expected: `drag_sources == drop_targets == badges`, `prepare_ok=1`, `reorder_ok=1` (or `-1` if fewer than 2), `payload_drop=1`, `list_drag=0`, `gtk warnings: 0`, no `Gtk-CRITICAL`. Then the Task 1 baseline diff: **allowed changes:** a drag-by-list-row probe line that now reports through the badge (explain it). A real pointer drag cannot be simulated headless: say so in the report, and note that the drop path itself is `assign_signal_to_row` (probed in Task 2).
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add -A src docs
|
||||||
|
git commit -m "ui: the signal badge is the drag handle (to a plot row, or to reorder); the list rows stop dragging"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Docs, the E-1608 run and the sweep of what the list's move touched
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/architecture.md`, `docs/claude/invariants-gtk-and-chrome.md` (only if it names the list as being in the sidebar), `src/ui_tour.cpp` (badges step text), `SPECS.md` is **not** touched.
|
||||||
|
|
||||||
|
- [ ] **Step 1: The E-1608 run (the spec's stress case)**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tools/mock_e1608.py & MOCK=$!
|
||||||
|
sleep 1
|
||||||
|
xvfb-run -a -s "-screen 0 1400x900x24" sh -c 'GDK_BACKEND=x11 GSK_RENDERER=cairo UDPSCOPE_EXIT_AFTER_MS=7000 UDPSCOPE_ENABLE_ALL_MS=1500 UDPSCOPE_PROBE_SIGNAL_BADGES_MS=3000 ./build/udpscope --source e1608 --source-opt address=127.0.0.1 > /tmp/ui-signals-e1608.log 2>&1 & sleep 4.5; import -window root /tmp/ui-signals-e1608.png; wait'
|
||||||
|
kill $MOCK
|
||||||
|
grep -E '\[sigbadges\]|Gtk-|gtk warnings' /tmp/ui-signals-e1608.log
|
||||||
|
```
|
||||||
|
Expected: `overflow=1 overflow_text=<N> signals plus_visible=0`, `bar_h_fixed=1`, and `bar_min_w` equal to the UDPS run's (the scroller keeps it constant -- if it differs by more than the one badge's rounding, the scroller is not constraining: fix before going on). Look at the screenshot once: the "N signals" badge, Trigger and Source on the right, bar height unchanged. Then open the `signals` page from a probe or by `g`, activate a row (E-1608 has up to ~17): the page for it exists (`[signalpage]` run against the mock too: `UDPSCOPE_PROBE_SIGNAL_PAGES_MS=3000`). Kill the mock.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Docs**
|
||||||
|
|
||||||
|
- `README.md`: the signals section describes the badge bar (signal badges, `+`, the six-badge overflow, drag to a plot or to reorder, the per-signal page) instead of the list in the sidebar; state that the order is kept for the session only.
|
||||||
|
- `docs/claude/verification.md`: add `UDPSCOPE_PROBE_SIGNAL_PAGES_MS` and `UDPSCOPE_PROBE_SIGNAL_BADGES_MS` next to the badge probes; `docs/claude/probe-readings.md`: the `[signalpage]` line and the two `[sigbadges]` lines, each field with the value it must read and the defect a wrong reading names (the fields above).
|
||||||
|
- `docs/claude/invariants-plot-and-layout.md`: one rule -- the badge bar's signal badges live in a scroller so the bar's minimum width never depends on the signal count (the E-1608 case), and the bar's height is fixed.
|
||||||
|
- `docs/claude/architecture.md`: the page ids now implemented (`signals`, `signal:<name>`), `ui_signal_page.cpp`, and that the dock holds the cursors and measurements only. `docs/claude/invariants-gtk-and-chrome.md`: if it describes `outlive_the_rebuild` or the list's drag/sidebar hiding, say it now covers the signal page's buttons and that the list is no longer a drag source.
|
||||||
|
- `src/ui_tour.cpp`: the `"badges"` step text becomes `"Setup is reached by the thing it configures. A badge per signal opens that signal's colour, calibration and plot; the + opens the full list; Trigger and Source open their own forms. Drag a signal badge onto a plot to draw it there, or onto another badge to reorder. Click again, press Esc or click the plot to close the panel."`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Final run**
|
||||||
|
|
||||||
|
Build from scratch (`rm -rf build/CMakeFiles/udpscope.dir; cmake --build build -j 2>&1 | grep -c warning` -- expected exactly `1`, the icons' warning; if a clean rebuild is too slow, report the incremental result instead), the UDPS baseline diff, and the slide-over / badges / signal-pages / signal-badges / tour-surface probes once each. Report every baseline difference with its explanation.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git add -A src docs README.md
|
||||||
|
git commit -m "docs: the signal badges, the signal pages and the six-badge overflow (step 2 of the redesign)"
|
||||||
|
```
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Events in the dock, the left pane deleted -- Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Step 4 of the badges/dock redesign (spec `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md`): a compact Events section in the dock, the full events list and its actions on an `events` slide-over page, the left events pane / divider / header toggle deleted, and the Mallard manual, USER-GUIDE and remaining docs swept for the new layout.
|
||||||
|
|
||||||
|
**Architecture:** The dock gets a third block under the readouts (`build_events_dock` in `src/ui_dock.cpp`): one compact row per `EventRecord` (title, time, small marker; no thumbnails), a "Manage" button, one click replays. The existing pane widgets (`events-pane.ui`: head with Live, Compare, Accumulate, the multi-select list with thumbnails) stop being a `GtkRevealer` beside the plot and become the content of the `events` slide-over page; the list keeps its builder, thumbnail cache and selection logic. Both views are rebuilt from the same `event_store->list()` by the same refresh entry point.
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4/libadwaita, the headless self-check probes (no unit tests).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- The dock is fixed at 280 px; dock event rows are ellipsized labels with fixed `width-chars`/`max-width-chars`; no thumbnails in the dock (layout must not depend on content).
|
||||||
|
- No new overlay child; the page uses `slideover_add_page(App&, id, title, widget)`; the page id is `events`.
|
||||||
|
- "Disable, do not hide": the dock's Events block stays on screen, insensitive, when unavailable. **Availability rule (deviation from the spec's "insensitive outside trigger mode", kept on purpose):** the existing `sync_events_availability` condition (`event_recording || replaying || events_cached`) -- a replay or a group leaves trigger mode, and a block that greys itself under the click that started it is the trap `invariants-trigger-and-pinning.md` records. Document the deviation in README and `docs/claude`.
|
||||||
|
- A handler that rebuilds destroys the widgets it was emitted from: use `outlive_the_rebuild(widget)`. `refresh_events_pane` must keep nulling `events_thumb` before it removes children. `update_group_buttons` keeps its `app.window == nullptr` teardown guard.
|
||||||
|
- `model->signals()` is read only under `model->mutex()`.
|
||||||
|
- The page content is the pane's `events_box`, never the `GtkRevealer` (an unrevealed revealer measures 0x0).
|
||||||
|
- Selection survives marking: `mark_events_on_screen` updates titles in place and now also updates the dock rows' marker; it never rebuilds the page list.
|
||||||
|
- Every new source file carries the SPDX MIT header (this plan adds none).
|
||||||
|
- Baselines: clean build = 1 warning (icons `-Woverlength-strings`), incremental = 0, `gtk warnings: 0`, no `Gtk-CRITICAL`. Probes need a tour-seen config for slide-over page legs (see `.superpowers/sdd/task-5-report.md`, `verification.md`). Mocks: `tools/mock_udpstreamer.py` (8114), `tools/mock_e1608.py` (54211); run serially, kill them.
|
||||||
|
- The session file has no events-pane field (`events_pane_width` is memory only); there is nothing to migrate. README's claim that the width is remembered across sessions is wrong and is removed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: The Events block in the dock (the left pane still exists)
|
||||||
|
|
||||||
|
Adds the dock block so the app stays whole; nothing is removed.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_dock.cpp`, `src/ui_app.hpp`, `src/ui_events.cpp`, `src/ui.cpp` (dock assembly, availability sync, tick), `src/ui_probe.cpp`, `CMakeLists.txt` (only if a file is added; none expected)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (declared in `ui_app.hpp`, defined in `ui_dock.cpp`):
|
||||||
|
- `GtkWidget* build_events_dock(App& app);` -- vertical `GtkBox`: a row with the "Events" label and a "Manage" text `GtkButton` (calls `slideover_open(app, "events")`; a no-op until Task 2 registers the page), then a `GtkListBox` (`app.dock_events_list`, selection mode none, single-click activation) of compact rows, inside a `GtkScrolledWindow` with a fixed `min-content-height`/`max-content-height` (about 4 rows) and `vscrollbar-policy` automatic. Stores `app.dock_events_box`, `app.dock_events_list`, `app.dock_events_manage`.
|
||||||
|
- `void dock_rebuild_events(App& app);` -- clears and refills `dock_events_list` from `app.event_store->list()` (newest first, as the pane does): each row = a small marker label (`dock_event_marker`, see below), the title (ellipsized, same text as `event_row_title`), the time as the pane shows it (ellipsized, dim). With no records: one dim "no events yet" row. Attaches the `EventRecord` to the row as `"udpscope-event"` (same key as the pane). Null guard: a no-op before `build_events_dock` ran or when `app.event_store` is null.
|
||||||
|
- `void dock_mark_events(App& app);` -- updates each dock row's marker/title from the same data `mark_events_on_screen` uses (the event on screen and the group members), without rebuilding rows.
|
||||||
|
- `void dock_events_set_sensitive(App& app, bool available);` -- the one place the block's sensitivity is set; `sync_events_availability` calls it with its own `available` value.
|
||||||
|
- Row activation (single click): calls the existing `replay_event(app, record)` that `on_event_row_activated` (`ui_events.cpp:961`) calls, with the same argument it passes; do not duplicate the replay logic -- extract the shared body if `on_event_row_activated` has any.
|
||||||
|
- Consumes: `App::event_store`, `EventRecord`, `event_row_title()` (`ui_events.cpp:784`), `replay_event`, `mark_events_on_screen`, `slideover_open`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build the block.** Write `build_events_dock`, `dock_rebuild_events`, `dock_mark_events`, `dock_events_set_sensitive` in `ui_dock.cpp`. Marker: a single-character label -- the on-screen event gets a filled marker, the group members another, others none (read how `mark_events_on_screen` (`ui_events.cpp:796`) decides the on-screen/group titles and reuse its predicate; do not invent a second one). CSS: check `ui_widgets.cpp` for existing `.udpscope-event-*` classes and `dim-label` before adding any.
|
||||||
|
- [ ] **Step 2: Wire it.** Append `build_events_dock` to the dock after the readouts box (and a separator), in `ui.cpp` where `build_readouts_dock` is assembled. Call `dock_rebuild_events(app)` at the end of `refresh_events_pane`'s non-empty and empty paths (every place it finishes) **and** from `record_event` unconditionally (today's `record_event` refreshes the pane only if the revealer is revealed; the dock must follow every latch), and `dock_mark_events` from `mark_events_on_screen`. Call `dock_events_set_sensitive` from `sync_events_availability` (`ui_events.cpp:40-70`) with its `available` result, and once after building (insensitive until available).
|
||||||
|
- [ ] **Step 3: Probe.** In the existing events probe (`events_probe_next`, `UDPSCOPE_PROBE_EVENTS_MS`) print `dock events: rows=<n> pane_rows=<n> same_titles=<0|1> sensitive=<0|1> dock_w=<n>` after at least two latched events (the probe already produces them), where `same_titles` compares each dock title to the pane row's title label (`"udpscope-event-title"`) in order, and again after a replay (`on_screen` marker present on exactly one dock row). Add one click on a dock row to the probe and print `dock replay: clicked=<0|1> replaying=<0|1>` (the dock row click starts a replay as the pane's activate does).
|
||||||
|
- [ ] **Step 4: Build; run** the events probe (UDPS mock), the readouts probes (regression), and check `dock_w` <= 280, `gtk warnings: 0`.
|
||||||
|
- [ ] **Step 5: Commit** (`git add src/ui_dock.cpp src/ui_app.hpp src/ui_events.cpp src/ui.cpp src/ui_probe.cpp`; message `ui: compact events block in the dock`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: The `events` page; the left pane, divider and header toggle deleted
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_events.cpp`, `src/ui.cpp`, `src/ui/main-window.ui`, `src/ui/header-bar.ui`, `src/ui/events-pane.ui`, `src/ui_app.hpp`, `src/ui_trigger.cpp` (`:597-601`, `:723-724`), `src/ui_layout.cpp` (`:380` comment), `src/ui_widgets.cpp` (`:58-60` comment), `src/ui_probe.cpp`, `src/ui_dock.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1's `build_events_dock`, `dock_rebuild_events`, `dock_mark_events`, `dock_events_set_sensitive`; `slideover_add_page`, `slideover_open`.
|
||||||
|
- Produces: slide-over page `events` (title "Events") whose content is `events_box` of `events-pane.ui`; `build_events_pane(App&)` now returns that box (not a revealer); `App::events_revealer`, `App::events_divider`, `App::events_pane_width`, `sync_events_divider`, the header `events_toggle` and its handler are gone; `events_cached`/`sync_events_availability` keep driving the dock block's sensitivity (and the page's: set `events_box` insensitive with it).
|
||||||
|
|
||||||
|
- [ ] **Step 1: The page.** `events-pane.ui`: drop the `events_pane` revealer so `events_box` is the root (rename nothing else); keep `events_head`, `events_live`, `events_compare`, `events_accumulate`, `events_list`. `build_events_pane` stops creating `events_revealer`. Register the page next to the `cursors`/`measure` registrations in `ui.cpp`: `slideover_add_page(*app, "events", "Events", app->events_box)` (add the `events_box` member if absent). `record_event`'s refresh condition `gtk_revealer_get_child_revealed` becomes "the `events` page is open" (`slideover_page(app) == "events"`), because the dock refreshes independently; opening the page (the slide-over's open path or the Manage button) must refresh the list once so it is current -- put this in a `slideover_set_on_open`-style hook only if the slide-over already has one; otherwise refresh in the Manage button's handler and in `refresh_events_pane` callers that run while closed (cheap; the list is small) -- state in the report which you chose.
|
||||||
|
- [ ] **Step 2: Delete the pane.** `main-window.ui`: remove `events_divider`; `left_column` becomes the `side_view` content directly (keep every id inside it); fix the header comment (lines ~6, 23, 29). `ui.cpp`: remove `sync_events_divider`, its tick call, the `events_divider` creation (`~5201-5206`), the null guard (`~4998`), the toggle handler (`~4951-4959`) and the toolbar-order probe string entry (`~3010`); `header-bar.ui`: remove `events_toggle`; keep the `udpscope-events-symbolic` icon (used by `preferences.ui`). `ui_app.hpp`: remove the members and declarations (`events_divider`, `events_pane_width`, `events_revealer`, the toggle pointer). `sync_events_availability` loses the toggle/revealer branches, keeps computing `available`, and drives `dock_events_set_sensitive` plus the page's sensitivity. Fix the comments at `ui_trigger.cpp:597-601`, `:723-724`, `ui_layout.cpp:380`, `ui_widgets.cpp:58-60`.
|
||||||
|
- [ ] **Step 3: Probes.** Rewrite `events_probe_next` (`ui_probe.cpp:5590-5860`) to open the page with `slideover_open(*a, "events")` instead of clicking the toggle, then drive selection/Compare/Accumulate exactly as before on the page's list; delete the divider/revealer/`pane_w` reads; rewrite `report_events_availability` (`ui.cpp:5712-5724`) as `[events] available=<0|1> dock_sensitive=<0|1> page_sensitive=<0|1> page=<id>` and the `events pane:` dump (`ui.cpp:6242-6255`) as `events pane: compare= accumulate= list= nested=` (no revealer). Any layout dump or toolbar probe naming the toggle is updated. The plot's width must no longer change when events exist: add `plot_w_stable=<0|1>` comparing the plot area's allocated width before and after the first latched event (it must read 1; the old pane squeezed it).
|
||||||
|
- [ ] **Step 4: Run** the events probe, the trigger probes, the layout/toolbar probes, the readouts probes, one E-1608 run; `gtk warnings: 0`, no `Gtk-CRITICAL`, in particular none from `events_thumb`, `update_group_buttons` at teardown, or the removed revealer. Re-baseline `.superpowers/sdd/ui-signals-baseline-udps.txt` only where this step explains a difference.
|
||||||
|
- [ ] **Step 5: Commit** (`git add` the modified files by name; message `ui: the events list is a slide-over page; the left pane, its divider and the header toggle are gone`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Tour, context help, docs/claude, README
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_tour.cpp`, `src/ui_context_help.cpp` (`:102-110`), `README.md` (97, 841-850, 883, 1032, 1194, 3025-3027), `docs/claude/{architecture,verification,probe-readings,invariants-gtk-and-chrome,invariants-trigger-and-pinning,invariants-plot-and-layout}.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Tour and help.** The `dock` tour stop text mentions the Events block (what it lists, that one click replays, that Manage opens the list). The context-help hints `events` (anchored to `&App::events_head`, now on a page) and `events_record` follow how the `cursors`/`measures` hints were handled in step 3 (anchor on the page heading, shown while the page is open; reword "the events pane"). Run the help-figures and tour probes.
|
||||||
|
- [ ] **Step 2: docs/claude.** `architecture.md` (chrome section: the dock now has readouts and events; page `events`; `events` no longer "reserved", ~line 105), `invariants-gtk-and-chrome.md` (paned/revealer traps at ~212-228 lose the events divider; teardown ~281 and `events_thumb` ~297, thumb cache ~309 now say "the page's list"), `invariants-trigger-and-pinning.md` (~441-453: the availability rule drives the dock block's sensitivity; the spec's "outside trigger mode" deviation and why), `invariants-plot-and-layout.md` (no events divider; the plot is never squeezed by events), `verification.md` (~724 and the events probe wording), `probe-readings.md` (`dock events:`, `dock replay:`, the new `[events]` and `events pane:` lines and what each must read; the old `pane_w=240` rows and the `[events]` table near 1660-1675 rewritten).
|
||||||
|
- [ ] **Step 3: README.** Layout/dock description, the header (no Events toggle), the events page, the single-click replay in the dock, deviations: availability rule instead of "outside trigger mode"; the dock list has no thumbnails (the page does); delete the false "remembers the width" claim; update the pending-removal note at ~3025-3027 (it is done).
|
||||||
|
- [ ] **Step 4: Run** the tour and help-figure probes, one UDPS and one E-1608 run; `gtk warnings: 0`.
|
||||||
|
- [ ] **Step 5: Commit** (code: `ui: tour and help describe the events block`; docs: `docs: events in the dock and on its page`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: The Mallard manual and USER-GUIDE sweep for the whole redesign
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `help/C/org.udpscope.app/*.page` (`trigger.page` 169-196 "Events pane on the left", the history button, "Drag the pane's edge"; `overview.page` incl. its layout description and any figure caption; `signals.page`, `math.page`, `sessions.page`, plus any page that mentions the side panel, the sidebar tabs ("Measure", "Signals" tab), the header's sidebar toggle, or the Events toggle), `docs/USER-GUIDE.md`, `README.md` leftovers
|
||||||
|
|
||||||
|
- [ ] **Step 1: Find every stale mention.** `grep -rniE "side panel|sidebar|side pane|tab switcher|\bMeasure tab|Signals tab|events pane|history button|Events toggle|drag.*(edge|divider)" help/C docs/USER-GUIDE.md README.md src/ui_*.cpp` and read each hit in context; also check figures the manual embeds (`help/C/org.udpscope.app/figures/` and how they are generated -- `UDPSCOPE_*` help-figures probe in `verification.md`). List every hit and its disposition in the report.
|
||||||
|
- [ ] **Step 2: Rewrite** each page to the real UI: header bar (time window, Trigger, Pause, Cursors, Dock toggle, menu), the plot, the badge bar (signal badges, `+`, N signals, Trigger, Source), the slide-over pages (trigger, signals, signal:<name>, cursors, measure, events, source), the dock (Readouts: cursors, measurements; Events). Read the code or `docs/claude/architecture.md` to be accurate; do not invent shortcuts (`m` toggles Measurements, `g` the signals page -- check `src/shortcuts.cpp`). Regenerate figures only if the repository already has a script that does so headlessly; otherwise say which figures remain stale.
|
||||||
|
- [ ] **Step 3: Validate** the Mallard pages if `yelp-check` / `xmllint` is available (`xmllint --noout help/C/org.udpscope.app/*.page`); build with `cmake --build build -j` (the help may be compiled/installed by a target).
|
||||||
|
- [ ] **Step 4: Commit** (`docs: the manual and the user guide describe the badge bar, the dock and the slide-over pages`).
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# Compact readouts dock -- step 3 of the badge-bar redesign Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** The dock shows the cursors and the measurements as compact, read-only readouts (about 280 px wide, always on screen); the full cursors panel and the full measurements panel (mode, reset, add, statistics, edit) become the `cursors` and `measure` slide-over pages, reached by an "Edit" text button in the dock, by the `m` key, and by clicking a measurement's dock line.
|
||||||
|
|
||||||
|
**Architecture:** A new unit `src/ui_dock.cpp` (spec: `ReadoutsDock`) builds the dock's "Readouts" section: a cursors block (a small grid mirroring `App::cursors_lines`) and a measurements block (one line per `App::measures`). It owns no formatting: `update_cursors_pane()` and `update_measures()` already compute the strings, and they now write them to the page's widget *and* to the dock's, from the one string. `cursors_panel` and the measures panel are *reparented* (as the trigger form and the signal list were) into slide-over pages with ids `cursors` and `measure`. The dock's old contents (the two full panels) leave it.
|
||||||
|
|
||||||
|
**Tech Stack:** C++20, GTK4, libadwaita >= 1.8, CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui_probe.cpp` probes, never a separate harness.
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` ("Right dock", "Slide-over panel", rollout step 3). Earlier plans: `2026-11-02-ui-badges-dock-shell.md` (step 1), `2026-11-03-ui-badges-signals.md` (step 2). Branch: `feature/ui-badges-dock` (continue on it).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
Copied from the spec and the repository's rules; every task includes them.
|
||||||
|
|
||||||
|
- **Right dock (spec, verbatim):** "Fixed width, about 280 px; always on screen, shown but insensitive until the stream has signals ('disable, do not hide'). It stays an `AdwOverlaySplitView` sidebar ... **Readouts** (top): the active cursor values and measurement values, read-only, one compact line per value, through the one formatter both panes share (`invariants-readouts-and-cursors.md`). An 'Edit' affordance opens the slide-over's `measure` or `cursors` page; clicking a measurement row opens that measurement's editor."
|
||||||
|
- **One formatter:** a readout quotes a stored sample and never a value between two (`docs/claude/invariants-readouts-and-cursors.md`); the dock and the page show the *same string*, set from the same variable in the same call -- no second `format_measure()` call site for the same value.
|
||||||
|
- **Layout must not depend on content** (`invariants-plot-and-layout.md`): the dock has a fixed width (280 px: `min-sidebar-width` = `max-sidebar-width` in `main-window.ui`), every dock label is ellipsized with a fixed `width-chars`/`max-width-chars`, so a long unit or value never widens the dock; the badge bar keeps its fixed height; the slide-over overlays and never resizes the plot.
|
||||||
|
- **A `GtkOverlay` child is picked wherever its allocation is**: this step adds no overlay child.
|
||||||
|
- Page ids: `trigger`, `signal:<name>`, `signals`, `measure`, `cursors`, `events`, `source-<id>`. This step adds `measure` and `cursors`. One slide-over page at a time; each page has its own scroller (`slideover_add_page` provides it); the open page is not saved.
|
||||||
|
- "Disable, do not hide": with the cursors off, the dock's cursor block stays on screen with `--` cells (the existing blanking in `update_cursors_pane`), insensitive; an empty measurement list shows one dim line, never nothing.
|
||||||
|
- Text buttons only for new controls ("Edit", not an icon). Existing icon buttons that move into the pages are not changed in this step.
|
||||||
|
- The widget-lifetime trap (`invariants-gtk-and-chrome.md`): a click handler that calls `rebuild_measures_pane()` / `rebuild_cursors_pane()` destroys widgets it may have been emitted from; hold the emitter with `outlive_the_rebuild(widget)` first. The dock's lines are rebuilt by those two functions, so a click on a dock line must not be handled by a widget that the rebuild destroys synchronously (open the dialog from the handler, rebuild later).
|
||||||
|
- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`).
|
||||||
|
- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`.
|
||||||
|
- Verification runs are short and serial; kill every mock you start (`tools/mock_udpstreamer.py` port 8114, `tools/mock_e1608.py` port 54211); seed a tour-seen config so the first-run tour does not close slide-over pages (see `.superpowers/sdd/task-5-report.md` for commands). Do not re-run to chase cosmetics.
|
||||||
|
- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`.
|
||||||
|
|
||||||
|
## Deliberate step-3 deviations from the spec (state them in the final report)
|
||||||
|
|
||||||
|
- **The measurement editor stays a dialog window** (`open_measure_dialog`): the `measure` page holds the list, Add, the Statistics switch and each row's edit button, which open that dialog. Moving the dialog's form into the slide-over is not needed for the readouts and is not done here.
|
||||||
|
- The `measure` and `cursors` pages hold the existing panels unchanged (heading, mode dropdown, reset, switch, grid / add, statistics, rows), so their content is not re-designed.
|
||||||
|
- Dock cursor lines carry the same cells as the page's grid (`who`, A, B, delta), at a smaller size; they are not reduced to the time line only, because the modes that read per-row values would then have no readout on screen.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Create/Modify | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `src/ui_dock.cpp` | Create | The dock's Readouts section: build, rebuild of the cursor lines and the measurement lines, the Edit buttons, the click-to-edit on a measurement line, the `[dock]` probe line. |
|
||||||
|
| `CMakeLists.txt` | Modify | Add `src/ui_dock.cpp` next to `src/ui_badges.cpp`. |
|
||||||
|
| `src/ui_app.hpp` | Modify | `App::DockCursorLine`, `App::dock_cursor_lines`, `App::dock_measure_labels`, `App::dock_readouts` widgets; declarations. |
|
||||||
|
| `src/ui_cursors.cpp` | Modify | `update_cursors_pane()` writes each cell's string to the page and to the dock; the rebuild calls the dock's rebuild. |
|
||||||
|
| `src/ui_measure.cpp` | Modify | `update_measures()` writes each reading to the page's label and to the dock's; `rebuild_measures_pane()` rebuilds the dock lines. |
|
||||||
|
| `src/ui.cpp` | Modify | Pages `cursors` / `measure` registered; panels removed from `dock_page`; `dock_show_section()` removed; `ShowMeasure` opens the page; dock assembly. |
|
||||||
|
| `src/ui/main-window.ui` | Modify | Dock width 280 (`min/max-sidebar-width`), `side_column` `width-request` 260, comments. |
|
||||||
|
| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | Steps / hints that pointed at the cursors and measurements panels in the dock now open the page (as the signals step does). |
|
||||||
|
| `src/ui_probe.cpp` | Modify | Probes that walked, measured or scrolled the panels while they lived in the dock; the `dock:` readings. |
|
||||||
|
| `README.md`, `docs/claude/*.md` | Modify | Task 3. (`USER-GUIDE.md` and the Mallard manual are spec step 4.) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: The compact readouts in the dock (panels still in the dock)
|
||||||
|
|
||||||
|
The Readouts section is added at the top of the dock, above the existing full panels, so the app stays whole: nothing is removed yet. Both read from the same strings.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ui_dock.cpp`
|
||||||
|
- Modify: `CMakeLists.txt`, `src/ui_app.hpp`, `src/ui_cursors.cpp`, `src/ui_measure.cpp`, `src/ui.cpp` (dock assembly, ~line 5161), `src/ui_probe.cpp` (a `[dock]` line in an existing probe)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces (declared in `ui_app.hpp`, defined in `ui_dock.cpp`):
|
||||||
|
- `GtkWidget* build_readouts_dock(App& app);` -- the Readouts box (a vertical `GtkBox`: a "Readouts" heading; a cursors block = a row with the "Cursors" label and an "Edit" `GtkButton` (text), then a `GtkGrid` of dock cursor lines; a measurements block = a row with the "Measurements" label and an "Edit" button, then a vertical box of lines). Stores `app.dock_cursor_grid`, `app.dock_measures_box`, `app.dock_cursors_edit`, `app.dock_measures_edit`.
|
||||||
|
- `void dock_rebuild_cursors(App& app);` -- called at the end of `rebuild_cursors_pane()`: rebuilds `app.dock_cursor_lines` (one `DockCursorLine {GtkWidget *label,*a,*b,*d;}` per `app.cursors_lines`, same order) in `dock_cursor_grid`. The first row is the same header `cursors_header()` writes (`who`/A/B/delta), small.
|
||||||
|
- `void dock_rebuild_measures(App& app);` -- called from `rebuild_measures_pane()` after the page's rows: one line per `app.measures` (signal chip, type name ellipsized, value label), pushing the value label to `app.dock_measure_labels` (same index as `app.measure_labels`). With no measurements: one dim line "no measurements".
|
||||||
|
- `void dock_cursor_cell(App& app, std::size_t line, const std::string& a, const std::string& b, const std::string& d);` and `void dock_measure_value(App& app, std::size_t i, const std::string& text);` -- set the dock's labels; no-ops when the index is out of range (mid-rebuild).
|
||||||
|
- Consumes: `App::cursors_lines`, `App::measures`, `App::measure_labels`, `measure_color()`, `chip_markup()`, `signal_color_index()`, `open_measure_dialog(App&, int)`, `slideover_open(App&, const std::string&)` (page ids used in Task 2; in this task the Edit buttons call it and the page may not exist yet -- `slideover_open` of an unknown id is a no-op; Task 2 registers the pages).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Dock widgets and rebuild functions.** Create `ui_dock.cpp` with the functions above. Fixed sizes: dock label `width-chars`/`max-width-chars` of 9 for value cells, ellipsize end, CSS class `caption` and `numeric` (check `ui_widgets.cpp` for the existing CSS before adding any). The whole Readouts box is the first child of `app.dock_page` (insert before the cursors panel with `gtk_box_prepend`).
|
||||||
|
- [ ] **Step 2: One string, two widgets.** In `update_cursors_pane()` (`ui_cursors.cpp`) replace the `put(line.a, va)` etc. by a helper that sets the page cell and calls `dock_cursor_cell(app, index, va, vb, vd)`; do the same for the blanking branch (`"--"`). In `update_measures()` (`ui_measure.cpp`, the two `gtk_label_set_text(label, ...)` sites that also write `app.measure_text[i]`) call `dock_measure_value(app, i, text)` from the same variable. Never format twice.
|
||||||
|
- [ ] **Step 3: Rebuild hooks.** `rebuild_cursors_pane()` (ui.cpp ~1564) ends with `dock_rebuild_cursors(app)`; `rebuild_measures_pane()` calls `dock_rebuild_measures(app)` before its final `update_measures(...)`. Both are no-ops until `build_readouts_dock` ran (null guards).
|
||||||
|
- [ ] **Step 4: Assemble.** In `ui.cpp` where the dock is assembled (after `gtk_box_append(dock_page, measures_panel)`), prepend `build_readouts_dock(*app)` and add its separator. The Edit buttons call `slideover_open(app, "cursors")` / `slideover_open(app, "measure")`; clicking a measurement line (a `GtkGestureClick` on the line box, not on the value label which is selectable) calls `open_measure_dialog(app, i)` with the index stored in the widget data, as `measures_row`'s edit button does.
|
||||||
|
- [ ] **Step 5: Probe.** Add to the existing cursors probe (find it with `grep -n "cursors" src/ui_probe.cpp`) and measures probe a reading proving the dock agrees with the page: print `dock cursors: lines=<n> page_lines=<n> same_text=<0|1>` and `dock measures: lines=<n> page_rows=<n> same_text=<0|1>` where `same_text` compares every dock label's text to the page label's text. Also `dock_w=<allocated width of the dock readouts box>` and assert it does not exceed the sidebar width. Run with 3 measurements and the cursors on in `Both` mode (cursors mode set via `rows->set_cursor_mode`).
|
||||||
|
- [ ] **Step 6: Build, run the probes against `tools/mock_udpstreamer.py`, verify `same_text=1`, `gtk warnings: 0`.**
|
||||||
|
- [ ] **Step 7: Commit** (`git add src/ui_dock.cpp CMakeLists.txt src/ui_app.hpp src/ui_cursors.cpp src/ui_measure.cpp src/ui.cpp src/ui_probe.cpp`; message `ui: compact cursor and measurement readouts at the top of the dock`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: The `cursors` and `measure` pages; the dock is only readouts
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui.cpp`, `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui_probe.cpp`, `src/ui_dock.cpp`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: Task 1's `build_readouts_dock`, `slideover_add_page(App&, id, title, widget)`, `slideover_toggle(App&, id)`.
|
||||||
|
- Produces: slide-over pages `cursors` (title "Cursors", content `app.cursors_panel`) and `measure` (title "Measurements", content the measures panel); `AppAction::ShowMeasure` toggles the `measure` page; `dock_show_section()` and its declaration/uses are deleted.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register the pages** next to the `trigger` and `signals` registrations (`ui.cpp` ~5156): `slideover_add_page(*app, "cursors", "Cursors", app->cursors_panel)` and `slideover_add_page(*app, "measure", "Measurements", measures_panel)`. Remove both `gtk_box_append(dock_page, ...)` of the panels and the separator between them; the dock keeps only `build_readouts_dock` (and, later, step 4's events).
|
||||||
|
- [ ] **Step 2: Keys and actions.** `AppAction::ShowMeasure` -> `slideover_toggle(app, "measure")`. Delete `dock_show_section()`. Make sure the tour/help code does not call it (`grep -n dock_show_section src`).
|
||||||
|
- [ ] **Step 3: Dock width.** `main-window.ui`: `min-sidebar-width`/`max-sidebar-width` 280, `side_column` `width-request` 260; update its comments.
|
||||||
|
- [ ] **Step 4: Badge/header state.** The page ids join the "open" bookkeeping: `slideover_page(app)` reports them; nothing in the badge bar marks them (no badge). The header's Cursors toggle still mirrors the `cursors_switch` inside the page (both exist).
|
||||||
|
- [ ] **Step 5: Probes.** Fix every probe that read the panels' allocation, sensitivity or scroll position while they sat in the dock (`dock_section_probe`, the `dock:` reading in the layout dump, the `cursors=... in the toolbar, dock=..., reset=... in the cursors pane` line, any that walks `dock_page`): they open the page first or read the widget tree without allocation. Add `[readouts] pages cursors=<0|1> measure=<0|1> dock_children=<n> dock_w=<n>` (pages registered; dock holds only the readouts box; width 280). Re-baseline `.superpowers/sdd/ui-signals-baseline-udps.txt` only where this step explains the difference.
|
||||||
|
- [ ] **Step 6: Run** the UDPS and E-1608 probe runs serially; `gtk warnings: 0`; Esc closes the pages; the measure Add and the measurement dialog still work from the page (the existing measure probes).
|
||||||
|
- [ ] **Step 7: Commit** (`ui: the cursors and measurements panels are slide-over pages; the dock is the readouts`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Tour, context help, docs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `README.md`, `docs/claude/{architecture,verification,probe-readings,invariants-plot-and-layout,invariants-readouts-and-cursors,invariants-gtk-and-chrome}.md`, `help/C/*.page` mentions of the cursors / measurements panels in the dock or the side panel (the full Mallard sweep and `USER-GUIDE.md` are step 4)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Tour.** Steps pointing at `cursors_panel`/`cursors_head`/`measures_head`/`side_box` now target the dock's readouts, or open the `cursors` / `measure` page through the existing "tour opens a page" mechanism (`invariants-gtk-and-chrome.md`), as the signals step does. Rewrite the step text: readouts in the dock, "Edit" opens the page.
|
||||||
|
- [ ] **Step 2: Context help.** Hints attached to `cursors_head` / `measures_head` live in the pages: show only while the page is open, or re-attach to the dock block headings -- whichever the existing layer supports without a second code path (follow how `signals_head` was handled). Run the help-figures probe and the tour probe (`UDPSCOPE_PROBE_*` in `verification.md`) and keep their lines sane.
|
||||||
|
- [ ] **Step 3: Docs.** README (layout, keys, the dock, readouts, deviations listed above), `architecture.md` (chrome section), `verification.md` and `probe-readings.md` (new `[dock]`/`[readouts]` lines, changed `dock:` readings), `invariants-readouts-and-cursors.md` (the dock and the page share one string: rule and why), `invariants-plot-and-layout.md` (dock width 280, ellipsized cells).
|
||||||
|
- [ ] **Step 4: Run** the tour/help-figure probes and one E-1608 run; confirm baselines (build 0 warnings, `gtk warnings: 0`).
|
||||||
|
- [ ] **Step 5: Commit** (`docs: the readouts dock, the cursors and measurement pages`).
|
||||||
@@ -0,0 +1,592 @@
|
|||||||
|
# TCPStreamer Source Kind Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add a `tcps` source kind that receives a MARTe2 `TCPStreamer` DataSource, standalone or through a `TCPStreamService`.
|
||||||
|
|
||||||
|
**Architecture:** `deps/udpsclient` gets an additive TCP transport mode (connect, optional SUBSCRIBE, framed reads reusing `read_tcp_frame`). `UdpsSource` takes a transport argument so the TCP kind reuses its callbacks. A new `tcps_kind.cpp` supplies the form and registration. The headless self-check gets a `[tcps]` probe against a new Python mock server.
|
||||||
|
|
||||||
|
**Tech Stack:** C99 (`deps/udpsclient`), C++17 + GTK4/libadwaita (`src/`), Python 3 + numpy (mock), CMake.
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-11-06-tcp-streamer-source-design.md`.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- There is no unit test suite; verification is the app's own headless self-check under `xvfb-run` against a mock. Read `docs/claude/verification.md` (baselines) before Task 3 and run probes **short and serial**, killing the mock afterwards.
|
||||||
|
- Baselines for every run: exactly **one** build warning (the pre-existing one; record the count before starting), and no `Gtk-CRITICAL`.
|
||||||
|
- Every new source file starts with the two-line SPDX header: `// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari` (`#` comments in Python, after the shebang).
|
||||||
|
- The GTK thread reaches a source only through `DataSource::post()`; `udps_*` names stay in `src/source/udps_*.cpp` and the new `tcps` files may include `udps_source.hpp` only.
|
||||||
|
- One connection carries one stream; multiplexed `SUBSCRIBE` lists are out of scope.
|
||||||
|
- Session naming for a non-UDPS kind is automatic (`session_host` -> `tcps-<identity>`, port 1, `source_kind=tcps`); do not touch `registry.cpp`'s session functions.
|
||||||
|
- A kind id is `[a-z0-9]+`: `tcps`. Display name: `TCPStreamer`. Default port 44500.
|
||||||
|
- Do not commit unless the task step says so.
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
| File | Change | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `deps/udpsclient/udps_client.h` | modify | `use_tcp`, `stream_name` config fields |
|
||||||
|
| `deps/udpsclient/udps_client.c` | modify | `connect_tcp()`, dispatch, no DISCONNECT frame in TCP mode, reorder window 0 |
|
||||||
|
| `tools/mock_tcpstreamer.py` | create | Mock TCPStreamer / TCPStreamService server, reuses `mock_udpstreamer.py` frame builders |
|
||||||
|
| `src/source/udps_source.hpp/.cpp` | modify | `Transport` enum, constructor argument, accessor |
|
||||||
|
| `src/source/tcps_kind.cpp` | create | `tcps` `SourceKind` and its form |
|
||||||
|
| `src/source/registry.hpp/.cpp` | modify | `make_tcps_kind()`, registration |
|
||||||
|
| `CMakeLists.txt` | modify | add `tcps_kind.cpp` |
|
||||||
|
| `src/ui_probe.cpp`, `src/ui.cpp` | modify | `[source-kinds]` sample, `[tcps]` probes and their env scheduling |
|
||||||
|
| `README.md`, `docs/claude/*.md` | modify | document the kind, the upstream change, the probes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: TCP transport in `deps/udpsclient`, and the mock server
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `deps/udpsclient/udps_client.h` (struct `udps_client_config_t`, around line 263-282)
|
||||||
|
- Modify: `deps/udpsclient/udps_client.c` (struct `udps_client` ~line 166, `send_control` ~1021, `do_connect` ~1076, `do_disconnect` ~1093, `udps_client_create` ~1260)
|
||||||
|
- Create: `tools/mock_tcpstreamer.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: `udps_client_config_t::use_tcp` (`int`, 0 = UDP as before) and `::stream_name` (`const char *`, NULL or empty = no SUBSCRIBE). Both default to zero/NULL through `udps_client_config_init()` (it memsets). `server_port` is the TCP port in TCP mode.
|
||||||
|
- Produces: `tools/mock_tcpstreamer.py --port P [--stream NAME] [--rate R] [--batch B] [--close-after S] [--drop-every N]`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the config fields** at the end of `udps_client_config_t` in `udps_client.h`, after `reorder_window_s`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
/**
|
||||||
|
* Non-zero selects the TCP transport (a MARTe2 TCPStreamer / TCPStreamService):
|
||||||
|
* @c server_port is the TCP port, no CONNECT or DISCONNECT frame is sent, the
|
||||||
|
* server streams CONFIG then DATA on connect, and the reorder window is forced
|
||||||
|
* to 0 (TCP is ordered, so a counter gap is a server-side drop). @c multicast_group,
|
||||||
|
* @c interface_addr and @c data_port are ignored.
|
||||||
|
*/
|
||||||
|
int use_tcp;
|
||||||
|
/**
|
||||||
|
* TCP mode only: when non-empty (1..255 bytes) a SUBSCRIBE frame naming this stream is
|
||||||
|
* sent right after every connect, as a TCPStreamService requires. A plain
|
||||||
|
* TCPStreamer ignores it. NULL or "" sends nothing.
|
||||||
|
*/
|
||||||
|
const char *stream_name;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Own a copy of the name.** In `struct udps_client`, after `char iface_addr[128];` add `char stream_name[256];`. In `udps_client_create`, next to the other `copy_str` calls add `copy_str(c->stream_name, sizeof c->stream_name, cfg->stream_name);` and after the `c->cfg.interface_addr = ...` line add:
|
||||||
|
|
||||||
|
```c
|
||||||
|
c->cfg.stream_name = c->stream_name;
|
||||||
|
if (c->cfg.use_tcp) {
|
||||||
|
c->cfg.multicast_group = NULL;
|
||||||
|
c->mcast_group[0] = '\0';
|
||||||
|
c->cfg.reorder_window_s = 0.0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also reject a name over 255 bytes: before `copy_str`, `if (cfg->use_tcp && cfg->stream_name != NULL && strlen(cfg->stream_name) > 255u) { free(c); return NULL; }` (place right after the `calloc` succeeds).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `connect_tcp()`** after `connect_multicast` (before `do_connect`):
|
||||||
|
|
||||||
|
```c
|
||||||
|
/** TCP transport: connect, then SUBSCRIBE when a stream name is set. */
|
||||||
|
static int connect_tcp(udps_client_t *c) {
|
||||||
|
struct timeval tv;
|
||||||
|
size_t name_len = strlen(c->stream_name);
|
||||||
|
|
||||||
|
c->tcp_fd = socket(AF_INET, SOCK_STREAM, 0);
|
||||||
|
if (c->tcp_fd < 0) {
|
||||||
|
return fail(c, "TCP socket() failed: %s", strerror(errno));
|
||||||
|
}
|
||||||
|
set_recv_buffer(c->tcp_fd, c->cfg.recv_buffer_bytes);
|
||||||
|
/* Linux bounds a blocking connect() with SO_SNDTIMEO, so an unreachable host
|
||||||
|
* cannot hold the polling thread for minutes. */
|
||||||
|
tv.tv_sec = 3;
|
||||||
|
tv.tv_usec = 0;
|
||||||
|
(void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, (socklen_t)sizeof tv);
|
||||||
|
if (connect(c->tcp_fd, (struct sockaddr *)&c->server_sa,
|
||||||
|
(socklen_t)sizeof c->server_sa) != 0) {
|
||||||
|
int e = errno;
|
||||||
|
close_sockets(c);
|
||||||
|
return fail(c, "TCP connect to %s:%u failed: %s", c->server_addr,
|
||||||
|
(unsigned)c->cfg.server_port, strerror(e));
|
||||||
|
}
|
||||||
|
/* A half-received frame must not wedge the poll loop (read_exact_tcp blocks). */
|
||||||
|
tv.tv_sec = 1;
|
||||||
|
(void)setsockopt(c->tcp_fd, SOL_SOCKET, SO_RCVTIMEO, &tv, (socklen_t)sizeof tv);
|
||||||
|
|
||||||
|
if (name_len > 0u) {
|
||||||
|
uint8_t pkt[UDPS_HEADER_SIZE + 255u];
|
||||||
|
build_header(pkt, UDPS_PKT_SUBSCRIBE, 0u, 0u, 1u, (uint32_t)name_len);
|
||||||
|
memcpy(pkt + UDPS_HEADER_SIZE, c->stream_name, name_len);
|
||||||
|
if (send(c->tcp_fd, pkt, UDPS_HEADER_SIZE + name_len, MSG_NOSIGNAL) !=
|
||||||
|
(ssize_t)(UDPS_HEADER_SIZE + name_len)) {
|
||||||
|
int e = errno;
|
||||||
|
close_sockets(c);
|
||||||
|
return fail(c, "SUBSCRIBE failed: %s", strerror(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `UDPS_PKT_SUBSCRIBE = 5 /**< Client -> server (TCP stream service): payload = stream name. */` to the packet-type enum in `udps_client.h` (add a comma after the `UDPS_PKT_DISCONNECT = 4` line).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Dispatch and disconnect.** Replace the first lines of `do_connect`:
|
||||||
|
|
||||||
|
```c
|
||||||
|
int rc = c->cfg.use_tcp ? connect_tcp(c)
|
||||||
|
: (c->mcast_group[0] != '\0') ? connect_multicast(c)
|
||||||
|
: connect_unicast(c);
|
||||||
|
```
|
||||||
|
|
||||||
|
In `do_disconnect`, replace `(void)send_control(c, UDPS_PKT_DISCONNECT);` with `if (!c->cfg.use_tcp) { (void)send_control(c, UDPS_PKT_DISCONNECT); }` (a write to a peer that closed would raise SIGPIPE and kill the application; the server detects the close itself).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Confirm the other TCP-path assumptions by reading, not guessing.** Read `deliver_payload` (~line 848) and confirm that with `reorder_window_s == 0` a counter jump delivers the frame with `lost > 0` and `stats.counter_gaps` incremented, and that `read_tcp_frame` -> `handle_packet` needs no change. If either is false, stop and report; do not patch around it.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Write the mock** `tools/mock_tcpstreamer.py` (`chmod +x`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
"""A minimal MARTe2 TCPStreamer / TCPStreamService producer, for exercising udpscope.
|
||||||
|
|
||||||
|
Listens on `--port`, serves one client at a time with the same frames as
|
||||||
|
mock_udpstreamer.py (CONFIG, then Accumulate DATA packets of sine/square/ramp/noise), each
|
||||||
|
framed UDPS-over-TCP. Without `--stream` it behaves like a standalone TCPStreamer: the
|
||||||
|
stream starts at once and anything the client sends is ignored. With `--stream NAME` it
|
||||||
|
behaves like a TCPStreamService: the client must open with a SUBSCRIBE frame naming NAME
|
||||||
|
within 5 s, otherwise the connection is closed.
|
||||||
|
|
||||||
|
./tools/mock_tcpstreamer.py --port 44500 --stream fast --close-after 6
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import select
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
import mock_udpstreamer as m # noqa: E402
|
||||||
|
|
||||||
|
PKT_SUBSCRIBE = 5
|
||||||
|
HEADER = 17
|
||||||
|
|
||||||
|
|
||||||
|
def read_exact(conn, n, deadline):
|
||||||
|
buf = b""
|
||||||
|
while len(buf) < n:
|
||||||
|
left = deadline - time.monotonic()
|
||||||
|
if left <= 0:
|
||||||
|
return None
|
||||||
|
r, _, _ = select.select([conn], [], [], left)
|
||||||
|
if not r:
|
||||||
|
return None
|
||||||
|
chunk = conn.recv(n - len(buf))
|
||||||
|
if not chunk:
|
||||||
|
return None
|
||||||
|
buf += chunk
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
def subscribed_to(conn, want):
|
||||||
|
"""True once a SUBSCRIBE frame naming `want` arrived within 5 s."""
|
||||||
|
deadline = time.monotonic() + 5.0
|
||||||
|
head = read_exact(conn, HEADER, deadline)
|
||||||
|
if head is None:
|
||||||
|
return False
|
||||||
|
magic, ptype, _, _, _, plen = struct.unpack("<IBIHHI", head)
|
||||||
|
if magic != m.MAGIC or ptype != PKT_SUBSCRIBE or not 0 < plen <= 4096:
|
||||||
|
return False
|
||||||
|
name = read_exact(conn, plen, deadline)
|
||||||
|
return name is not None and name.decode(errors="replace") == want
|
||||||
|
|
||||||
|
|
||||||
|
def serve(conn, args):
|
||||||
|
dt = 1.0 / args.rate
|
||||||
|
period = args.batch / args.rate
|
||||||
|
conn.sendall(m.build_config(args.rate, m.TIME_FULL_ARRAY, "V"))
|
||||||
|
counter, t_stream = 1, 0.0
|
||||||
|
started = time.monotonic()
|
||||||
|
next_send = started
|
||||||
|
while True:
|
||||||
|
now = time.monotonic()
|
||||||
|
if args.close_after > 0 and now - started >= args.close_after:
|
||||||
|
print("mock tcpstreamer: closing the connection (--close-after)", flush=True)
|
||||||
|
return
|
||||||
|
if now < next_send:
|
||||||
|
time.sleep(min(next_send - now, 0.002))
|
||||||
|
continue
|
||||||
|
pkt = m.build_data(counter, t_stream, dt, args.batch, args.freq)
|
||||||
|
if not (args.drop_every > 0 and counter % args.drop_every == 0):
|
||||||
|
conn.sendall(pkt)
|
||||||
|
counter += 1
|
||||||
|
t_stream += args.batch * dt
|
||||||
|
next_send += period
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--port", type=int, default=44500)
|
||||||
|
ap.add_argument("--stream", default="", help="TCPStreamService mode: require SUBSCRIBE of this name")
|
||||||
|
ap.add_argument("--rate", type=float, default=10000.0, help="samples per second")
|
||||||
|
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("--close-after", type=float, default=0.0, metavar="S",
|
||||||
|
help="close each connection after S seconds (the client must reconnect); 0 = never")
|
||||||
|
ap.add_argument("--drop-every", type=int, default=0, metavar="N",
|
||||||
|
help="skip sending every Nth packet but advance the counter (a server-side drop); 0 = never")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
srv.bind(("0.0.0.0", args.port))
|
||||||
|
srv.listen(4)
|
||||||
|
print("mock tcpstreamer: listening on :%d (%s)" % (
|
||||||
|
args.port, "service stream '%s'" % args.stream if args.stream else "standalone"), flush=True)
|
||||||
|
while True:
|
||||||
|
conn, addr = srv.accept()
|
||||||
|
print("mock tcpstreamer: client %s:%d" % addr, flush=True)
|
||||||
|
try:
|
||||||
|
if args.stream and not subscribed_to(conn, args.stream):
|
||||||
|
print("mock tcpstreamer: no valid SUBSCRIBE for '%s'; closing" % args.stream, flush=True)
|
||||||
|
continue
|
||||||
|
if args.stream:
|
||||||
|
print("mock tcpstreamer: SUBSCRIBE '%s'" % args.stream, flush=True)
|
||||||
|
serve(conn, args)
|
||||||
|
except (BrokenPipeError, ConnectionResetError):
|
||||||
|
print("mock tcpstreamer: client went away", flush=True)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Build the dependency and the app** with the project's usual command (see `docs/claude/build-and-run.md`; normally `cmake --build build -j`). Expected: it builds; the warning count equals the count recorded before the task. Run `python3 tools/mock_tcpstreamer.py --help` and `python3 -c "import ast,sys;ast.parse(open('tools/mock_tcpstreamer.py').read())"`: both succeed.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add deps/udpsclient/udps_client.h deps/udpsclient/udps_client.c tools/mock_tcpstreamer.py
|
||||||
|
git commit -m "udpsclient: a TCP transport mode (SUBSCRIBE, no CONNECT) and a TCP mock server"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: The `tcps` source kind
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/source/udps_source.hpp`, `src/source/udps_source.cpp`
|
||||||
|
- Create: `src/source/tcps_kind.cpp`
|
||||||
|
- Modify: `src/source/registry.hpp`, `src/source/registry.cpp`, `CMakeLists.txt` (source list near line 242-243)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `udps_client_config_t::use_tcp` / `::stream_name` from Task 1.
|
||||||
|
- Produces: `enum class UdpsSource::Transport { Udp, Tcp }`; `UdpsSource(const SourceConfig&, Transport = Transport::Udp)`; `Transport UdpsSource::transport() const`; `std::unique_ptr<SourceKind> make_tcps_kind()`; kind id `tcps` with config keys `server` (default `127.0.0.1`), `port` (default `44500`), `stream` (optional).
|
||||||
|
|
||||||
|
- [ ] **Step 1: `udps_source.hpp`.** Inside `class UdpsSource`, add before the constructor:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
enum class Transport { Udp, Tcp };
|
||||||
|
```
|
||||||
|
|
||||||
|
change the constructor to `explicit UdpsSource(const SourceConfig& cfg, Transport transport = Transport::Udp);`, add `Transport transport() const { return transport_; }` in the public section, and members `Transport transport_ = Transport::Udp;` and `std::string stream_;`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `udps_source.cpp`.** Replace the constructor:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
UdpsSource::UdpsSource(const SourceConfig& cfg, Transport transport)
|
||||||
|
: transport_(transport),
|
||||||
|
server_(cfg.get("server", "127.0.0.1")),
|
||||||
|
port_(static_cast<std::uint16_t>(
|
||||||
|
cfg.get_int("port", transport == Transport::Tcp ? 44500 : 8000))),
|
||||||
|
multicast_(transport == Transport::Tcp ? std::string() : cfg.get("multicast")),
|
||||||
|
interface_(transport == Transport::Tcp ? std::string() : cfg.get("interface")),
|
||||||
|
stream_(transport == Transport::Tcp ? cfg.get("stream") : std::string()),
|
||||||
|
recv_buffer_(static_cast<int>(cfg.get_int("recv_buffer", 8 * 1024 * 1024))) {}
|
||||||
|
```
|
||||||
|
|
||||||
|
Member declaration order must match the initialiser order: in the header declare `transport_` before `server_`, and `stream_` after `interface_` and before `recv_buffer_` (an out-of-order initialiser is a `-Wreorder` warning, which breaks the one-warning baseline). In `open()`, after `c.keepalive_interval_s = 1.0;` add:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (transport_ == Transport::Tcp) {
|
||||||
|
c.use_tcp = 1;
|
||||||
|
c.stream_name = stream_.empty() ? nullptr : stream_.c_str();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`datagram_cb` registration stays; in TCP mode the client never fires it. Leave `set_datagram_tap` as is.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `src/source/tcps_kind.cpp`.** Copy the helper block of `src/source/udps_kind.cpp` (`ChangeCb`, `entry_row`, `on_edited`, `on_value`, `hook_value`, `hook`, `text_of`; they are in an anonymous namespace, so the copy is local, as three kinds already do) and add the kind:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "source/registry.hpp"
|
||||||
|
#include "source/udps_source.hpp"
|
||||||
|
|
||||||
|
#include <adwaita.h>
|
||||||
|
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/* ChangeCb, entry_row, on_edited, on_value, hook_value, hook, text_of:
|
||||||
|
the same six helpers as udps_kind.cpp, verbatim. */
|
||||||
|
|
||||||
|
class TcpsKind final : public SourceKind {
|
||||||
|
public:
|
||||||
|
std::string id() const override { return "tcps"; }
|
||||||
|
std::string display_name() const override { return "TCPStreamer"; }
|
||||||
|
|
||||||
|
std::string identity(const SourceConfig& cfg) const override {
|
||||||
|
std::string s = cfg.get("server", "127.0.0.1") + ":" + std::to_string(cfg.get_int("port", 44500));
|
||||||
|
if (!cfg.get("stream").empty()) s += "/" + cfg.get("stream");
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unique_ptr<DataSource> make(const SourceConfig& cfg) const override {
|
||||||
|
return std::make_unique<UdpsSource>(cfg, UdpsSource::Transport::Tcp);
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigForm make_config_form() const override {
|
||||||
|
GtkWidget* server = entry_row("_Server");
|
||||||
|
GtkWidget* port = GTK_WIDGET(adw_spin_row_new_with_range(1, 65535, 1));
|
||||||
|
adw_preferences_row_set_title(ADW_PREFERENCES_ROW(port), "_Port");
|
||||||
|
adw_preferences_row_set_use_underline(ADW_PREFERENCES_ROW(port), TRUE);
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(port), 44500);
|
||||||
|
GtkWidget* stream = entry_row("S_tream name");
|
||||||
|
|
||||||
|
ChangeCb cb = std::make_shared<std::function<void()>>();
|
||||||
|
hook(server, cb);
|
||||||
|
hook_value(port, cb);
|
||||||
|
hook(stream, cb);
|
||||||
|
|
||||||
|
ConfigForm f;
|
||||||
|
f.rows = {server, port, stream};
|
||||||
|
f.description =
|
||||||
|
"Leave the stream name empty for a standalone TCPStreamer; name the stream for a TCPStreamService";
|
||||||
|
f.focus = server;
|
||||||
|
f.write = [=](const SourceConfig& c) {
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(server), c.get("server", "127.0.0.1").c_str());
|
||||||
|
adw_spin_row_set_value(ADW_SPIN_ROW(port), static_cast<double>(c.get_int("port", 44500)));
|
||||||
|
gtk_editable_set_text(GTK_EDITABLE(stream), c.get("stream").c_str());
|
||||||
|
};
|
||||||
|
f.read = [=]() {
|
||||||
|
SourceConfig c;
|
||||||
|
c.set("server", text_of(server));
|
||||||
|
c.set("port", std::to_string(static_cast<long>(adw_spin_row_get_value(ADW_SPIN_ROW(port)))));
|
||||||
|
if (!text_of(stream).empty()) c.set("stream", text_of(stream));
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
// A stream name is 1..255 bytes and, being the SUBSCRIBE payload, may not hold a comma
|
||||||
|
// (that would select a multiplexed list, which this kind does not support).
|
||||||
|
f.valid = [=]() {
|
||||||
|
const std::string s = text_of(stream);
|
||||||
|
return !text_of(server).empty() && s.size() <= 255 && s.find(',') == std::string::npos;
|
||||||
|
};
|
||||||
|
f.on_change = [cb](std::function<void()> fn) { *cb = std::move(fn); };
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::unique_ptr<SourceKind> make_tcps_kind() { return std::make_unique<TcpsKind>(); }
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
|
```
|
||||||
|
|
||||||
|
When writing the file, paste the six real helper definitions in place of the comment block. Check `udps_kind.cpp` includes `<functional>` transitively (it does through `source_kind.hpp`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Register.** `registry.hpp`: add `std::unique_ptr<SourceKind> make_tcps_kind();` after `make_udps_kind()` and add `tcps_kind.cpp` to the comment's file list. `registry.cpp`: in `register_builtin_sources()` add `register_kind(make_tcps_kind());` right after `make_udps_kind()` (so the chooser lists the two network kinds together). `CMakeLists.txt`: add `src/source/tcps_kind.cpp` after `src/source/udps_kind.cpp`.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build.** `cmake --build build -j`. Expected: builds, warning count unchanged from the baseline.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Smoke-run against the mock** (serial, kill the mock after):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 tools/mock_tcpstreamer.py --port 44500 &
|
||||||
|
MOCK=$!
|
||||||
|
UDPSCOPE_EXIT_AFTER_MS=4000 xvfb-run -a build/udpscope --source tcps --source-opt server=127.0.0.1 --source-opt port=44500 2>&1 | tail -20
|
||||||
|
kill $MOCK
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no `Gtk-CRITICAL`; the mock prints `client 127.0.0.1:...`. (If the app's CLI spells the options differently, take the spelling from `docs/claude/build-and-run.md`.) Then the same with `--stream fast` on the mock and `--source-opt stream=fast` on the app: the mock prints `SUBSCRIBE 'fast'`. Then once with the wrong name (`stream=slow`): the mock prints `no valid SUBSCRIBE`, and the app keeps retrying without a stderr flood.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/source/udps_source.hpp src/source/udps_source.cpp src/source/tcps_kind.cpp src/source/registry.hpp src/source/registry.cpp CMakeLists.txt
|
||||||
|
git commit -m "sources: a TCPStreamer kind (standalone or TCPStreamService) on the UDPS client's TCP mode"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Probes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ui_probe.cpp` (sample at ~line 2683 `const auto sample`; new `[tcps]` functions after `flatudp_probe`, ~line 3703), `src/ui.cpp` (env scheduling after the `UDPSCOPE_PROBE_FLATUDP_GAP` block, ~line 7657)
|
||||||
|
- Declarations: wherever `flatudp_probe` / `flatudp_probe_gap` are declared (grep `flatudp_probe_gap` in `src/*.hpp`; add the two new functions beside them)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `UdpsSource::transport()`, kind `tcps`, `a->model->stats()` fields `connected`, `frames`, `reconnects`, `a->model->signals()`, `a->capture_button`.
|
||||||
|
- Produces: env vars `UDPSCOPE_PROBE_TCPS=<ms>` and `UDPSCOPE_PROBE_TCPS_RECONNECT=<ms>`; stderr lines `[tcps] stream:`, `[tcps] capture button:`, `[tcps] reconnect:`; a `[source-kinds] form tcps:` line.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Form round trip.** In `source_kinds_probe`'s `sample` lambda add a branch:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
} else if (id == "tcps") {
|
||||||
|
c.set("server", "10.0.0.7");
|
||||||
|
c.set("port", "44501");
|
||||||
|
c.set("stream", "fast");
|
||||||
|
```
|
||||||
|
|
||||||
|
Build and run `UDPSCOPE_PROBE_SOURCE_KINDS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 xvfb-run -a build/udpscope --source probe --source-opt rate=500 2>&1 | grep source-kinds`. Expected: `kinds=udps,tcps,e1608,flatudp,probe` and `form tcps: round_trip=1 valid=1`. The `kinds=` line changes for every run from now on, so update `docs/claude/probe-readings.md` (~line 2120-2123, 2135) and `docs/claude/verification.md` (~line 579, "Nine `[source-kinds]` lines" -> ten, "one `form` line per registered kind: `udps`, `tcps`, ...") in Task 4.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the source accessor and stream probe** in `ui_probe.cpp`, after `flatudp_probe`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
UdpsSource* tcps_source(App* a) {
|
||||||
|
UdpsSource* s = a->acq != nullptr ? dynamic_cast<UdpsSource*>(a->acq->source()) : nullptr;
|
||||||
|
return s != nullptr && s->transport() == UdpsSource::Transport::Tcp ? s : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint64_t g_tcps_frames_before = 0;
|
||||||
|
std::uint64_t g_tcps_reconnects_before = 0;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
/// Phase 2, 1 s after the signals were enabled: connected, the mock's four channels, frames
|
||||||
|
/// flowing, and the capture button present but disabled (the capture is of the UDP socket).
|
||||||
|
gboolean tcps_probe_report(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
|
||||||
|
std::string names;
|
||||||
|
for (const SignalInfo& s : a->model->signals()) names += (names.empty() ? "" : ",") + s.name;
|
||||||
|
const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed);
|
||||||
|
const bool ok = up && names == "sine,square,ramp,noise" && frames > 0;
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[tcps] stream: connected=%d signals=%zu names=%s frames=%" G_GUINT64_FORMAT " ok=%d\n",
|
||||||
|
up ? 1 : 0, a->model->signals().size(), names.c_str(), frames, ok ? 1 : 0);
|
||||||
|
std::fprintf(stderr, "[tcps] capture button: present=%d sensitive=%d\n",
|
||||||
|
a->capture_button != nullptr ? 1 : 0,
|
||||||
|
a->capture_button != nullptr && gtk_widget_get_sensitive(a->capture_button) ? 1 : 0);
|
||||||
|
std::fflush(stderr);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase 1: enable every signal in one structural change, report a second later.
|
||||||
|
gboolean tcps_probe(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
if (tcps_source(a) == nullptr) {
|
||||||
|
std::fprintf(stderr, "[tcps] no tcps source (run with --source tcps)\n");
|
||||||
|
std::fflush(stderr);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
std::vector<std::size_t> all(a->model->signals().size());
|
||||||
|
for (std::size_t i = 0; i < all.size(); ++i) all[i] = i;
|
||||||
|
if (!all.empty()) a->model->set_signals_enabled(all, true);
|
||||||
|
g_timeout_add(1000, tcps_probe_report, a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After the mock closed the connection (`--close-after`): the client must have reconnected and
|
||||||
|
/// the frame count moved on. Phase 2 reads 1.5 s after phase 1 recorded the baseline.
|
||||||
|
gboolean tcps_probe_reconnect_report(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
const bool up = a->model->stats().connected.load(std::memory_order_relaxed);
|
||||||
|
const std::uint64_t frames = a->model->stats().frames.load(std::memory_order_relaxed);
|
||||||
|
const std::uint64_t rc = a->model->stats().reconnects.load(std::memory_order_relaxed);
|
||||||
|
const bool ok = up && frames > g_tcps_frames_before && rc > g_tcps_reconnects_before;
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[tcps] reconnect: connected=%d frames_before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT
|
||||||
|
" reconnects_before=%" G_GUINT64_FORMAT " after=%" G_GUINT64_FORMAT " ok=%d\n",
|
||||||
|
up ? 1 : 0, g_tcps_frames_before, frames, g_tcps_reconnects_before, rc, ok ? 1 : 0);
|
||||||
|
std::fflush(stderr);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean tcps_probe_reconnect(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
g_tcps_frames_before = a->model->stats().frames.load(std::memory_order_relaxed);
|
||||||
|
g_tcps_reconnects_before = a->model->stats().reconnects.load(std::memory_order_relaxed);
|
||||||
|
g_timeout_add(1500, tcps_probe_reconnect_report, a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Make sure `ui_probe.cpp` includes `source/udps_source.hpp` (add it beside the flatudp includes at line 11-12 if absent). The `reconnects` stat is fed from `SourceStats::reconnects` by `Acquisition`; if after Task 3 Step 4 it reads 0 while the client demonstrably reconnected, `Acquisition` does not map it for this path: report that rather than weakening the check.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Schedule and declare.** Add the two declarations next to `flatudp_probe_gap`'s, and in `ui.cpp` after the `UDPSCOPE_PROBE_FLATUDP_GAP` block:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_TCPS")) {
|
||||||
|
g_timeout_add(static_cast<guint>(std::atoi(env)), tcps_probe, &app);
|
||||||
|
}
|
||||||
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_TCPS_RECONNECT")) {
|
||||||
|
g_timeout_add(static_cast<guint>(std::atoi(env)), tcps_probe_reconnect, &app);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the three scenarios**, each short and serial, mock killed after each, stderr filtered with `grep -E '^\[tcps\]|Gtk-CRITICAL|warning'`:
|
||||||
|
|
||||||
|
1. Standalone: mock `--port 44500`; app `--source tcps --source-opt port=44500`, `UDPSCOPE_PROBE_TCPS=2500 UDPSCOPE_EXIT_AFTER_MS=5000`. Expected `[tcps] stream: connected=1 signals=4 names=sine,square,ramp,noise frames=<n> ok=1` (n > 0) and `[tcps] capture button: present=1 sensitive=0`.
|
||||||
|
2. Service: mock `--port 44500 --stream fast`; app additionally `--source-opt stream=fast`. Same expectations.
|
||||||
|
3. Reconnect: mock `--port 44500 --close-after 3`; app `UDPSCOPE_PROBE_TCPS=2500 UDPSCOPE_PROBE_TCPS_RECONNECT=4500 UDPSCOPE_EXIT_AFTER_MS=8000`. Expected `[tcps] reconnect: connected=1 ... ok=1`.
|
||||||
|
|
||||||
|
Also check the no-server case: no mock, `UDPSCOPE_PROBE_TCPS=2500`: `connected=0 ... ok=0` is a reading, not a crash; no stderr flood (a retry every 0.5 s is acceptable, more than a few lines per second is not).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Baselines.** A full rebuild shows the same warning count as before the plan, and none of the runs printed `Gtk-CRITICAL`. Then re-run the source-kinds probe (Step 1) and confirm the existing `udps`/`e1608`/`flatudp`/`probe` form lines still read `round_trip=1 valid=1`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ui_probe.cpp src/ui.cpp
|
||||||
|
git commit -m "probes: the TCPStreamer kind end to end (standalone, service, reconnect)"
|
||||||
|
```
|
||||||
|
|
||||||
|
(Include the header that carries the probe declarations in `git add` if Step 3 touched one.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Documentation
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `README.md`, `docs/claude/architecture.md`, `docs/claude/build-and-run.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `CLAUDE.md` (only its intro sentence if it enumerates the source kinds)
|
||||||
|
|
||||||
|
- [ ] **Step 1: `docs/claude/build-and-run.md`.** The `deps/udpsclient` section says "Five things were changed". Make it six and add item 6: "**A TCP transport mode.** `udps_client_config_t::use_tcp` / `stream_name` select a TCP connection to a MARTe2 `TCPStreamer` or `TCPStreamService`: `connect_tcp()` (3 s connect bound via `SO_SNDTIMEO`, 1 s receive bound, an optional `SUBSCRIBE` frame), the existing `read_tcp_frame()` for the frames, the reorder window forced to 0, no DISCONNECT frame on close (a write to a closed peer would raise SIGPIPE), and no datagram tap. A frame must fit `UDPS_MAX_DATAGRAM_BYTES` (the server's `MaxPayloadSize` default of 60000 does)." Also update the earlier sentence "`deps/udpsclient` gained one additive entry point" to mention the TCP mode.
|
||||||
|
|
||||||
|
- [ ] **Step 2: `docs/claude/architecture.md` (Sources section).** Add a bullet: "**The TCPStreamer kind (`tcps`) is `UdpsSource` with `Transport::Tcp`** ... `src/source/tcps_kind.cpp` is only the form and identity (`host:port` or `host:port/stream`); there is no new `DataSource`. One stream per connection (no multiplexed `SUBSCRIBE` list: a source is single-timebase; merge on the server with `TCPStreamJoin`). `has_datagrams()` is false, so the capture button stays disabled. `Disconnected` follows the client's 2 s silence timeout or a closed connection, and it reconnects every 0.5 s. A stream whose frames are further apart than 2 s therefore reconnects continually, as UDPS does." Fix the bullet that says `UdpsSource` "wraps `deps/udpsclient` unchanged".
|
||||||
|
|
||||||
|
- [ ] **Step 3: `docs/claude/verification.md` and `probe-readings.md`.** Verification: update "Nine `[source-kinds]` lines (... `udps`, `e1608`, `flatudp`, `probe`)" to include `tcps`; add `UDPSCOPE_PROBE_TCPS` and `UDPSCOPE_PROBE_TCPS_RECONNECT` entries beside the FLATUDP ones, with the three scenarios from Task 3 Step 4 (commands, mock options, exit times). Probe readings: update `kinds=` (line ~2120 and ~2135) to `udps,tcps,e1608,flatudp,probe`, add `[source-kinds] form tcps: round_trip=1 valid=1` (and the form sample's keys: server, port, stream), and add a `## [tcps]` section after `[flatudp]` with the sample output of the three lines and what a wrong reading means (`connected=0` with a mock running: SUBSCRIBE or connect failing; `names` wrong: CONFIG not decoded; `sensitive=1`: the capture button was enabled for a kind with no datagrams; `reconnect ... ok=0`: the client did not come back after the close, or `Acquisition` does not map `reconnects`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: `README.md`.** Wherever it lists the source kinds or the command-line `--source` values, add `tcps` with its options (`server`, `port` default 44500, `stream` optional), one line saying it receives a MARTe2 `TCPStreamer`/`TCPStreamService`, and under known limitations: single stream per connection, no packet capture over TCP, frames up to 65 KB (the server's `MaxPayloadSize`). If `CLAUDE.md`'s intro sentence enumerates the kinds, add `tcps` there too.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Read-through.** `grep -rn "Nine \`\[source-kinds\]\|Five things\|unchanged" README.md CLAUDE.md docs/claude` and fix every statement the change made false.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add README.md CLAUDE.md docs/claude
|
||||||
|
git commit -m "docs: the TCPStreamer source kind, the client's TCP mode, and its probes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-review (against the spec)
|
||||||
|
|
||||||
|
- Wire/scope (single stream, SUBSCRIBE only with a name): Task 1 Steps 1-4, Task 2 Step 3 (`valid` rejects a comma).
|
||||||
|
- `udpsclient` TCP mode (use_tcp, stream_name, connect_tcp, reorder 0, no tap, additive, recorded as a sixth change): Task 1; recording: Task 4 Step 1.
|
||||||
|
- Source adapter reuse via transport argument; form rows, description, identity, session naming (automatic), registration and CMake: Task 2.
|
||||||
|
- Verification (fake server, probe lines for signals/frames/reconnect, baselines, real smoke test): Task 3 uses a Python mock rather than the in-process fake server the spec named, because the existing probes all use external mocks under `tools/`. A smoke test against a real MARTe `TCPStreamer` is not in the plan; do it only if one is runnable.
|
||||||
|
- Docs: Task 4.
|
||||||
|
- Types: `UdpsSource::Transport`, `transport()`, `make_tcps_kind()`, `tcps_source()`, env names are used identically across tasks.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# Three things a bench scope has and udpscope does not: qualified triggers, measurement statistics, and a search over the buffer
|
||||||
|
|
||||||
|
udpscope triggers on one thing — a level crossing — reports each measurement as a single
|
||||||
|
number with no sense of how it has varied, and offers no way to ask where in the retained
|
||||||
|
buffer something happened. A survey of what bench scopes and the open-source viewers provide
|
||||||
|
turned up a longer list than this; it was narrowed deliberately, and what was cut is named in
|
||||||
|
**Scope** below.
|
||||||
|
|
||||||
|
The three features chosen are not independent. A qualified trigger condition and a search over
|
||||||
|
the buffer are the *same question* asked of live data and of recorded data, so they are built
|
||||||
|
on one evaluator rather than two, and the probe that proves they agree is part of the work.
|
||||||
|
Measurement statistics are separate but cheap: the pane's recompute pass already produces the
|
||||||
|
number a statistic accumulates.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- A shared, allocation-free `EventDetector` extracted out of `TriggerEngine`, carrying four
|
||||||
|
condition types: edge (as now), pulse width (with glitch as a comparison, not a fifth type),
|
||||||
|
window, and dropout.
|
||||||
|
- The trigger panel's new type row and the parameter rows each type needs.
|
||||||
|
- Per-measurement statistics — count, mean, standard deviation, min, max — accumulated one
|
||||||
|
observation per recompute, with a pane-wide toggle and a Reset.
|
||||||
|
- A `Search` section in the trigger panel that scans the retained ring for the trigger's own
|
||||||
|
condition, marks the hits, and navigates them by reusing the existing pause.
|
||||||
|
- Four new probes, and the mock waveforms they need.
|
||||||
|
- `SPECS.md` is not changed; `README.md` and `docs/claude/` are.
|
||||||
|
|
||||||
|
**Out, and deliberately:**
|
||||||
|
|
||||||
|
- **Reference waveforms** — declined explicitly.
|
||||||
|
- **PNG export** — the `.npy`/`.csv` export covers the need that matters; a screenshot is the
|
||||||
|
window manager's job.
|
||||||
|
- **FFT / spectrum** — needs a second `plot::Figure` with its own x units, which is a project
|
||||||
|
of its own and not a feature of this one.
|
||||||
|
- **Byte-bounded event history** — a second retention policy beside the ring's, with its own
|
||||||
|
eviction. Out for the same reason.
|
||||||
|
- No new measurement types. No change to `measure_scan_buckets()` /
|
||||||
|
`measure_accumulate_bucket()` or to the hysteresis band. No change to the decimation plan, the
|
||||||
|
partition, or the two-thread split.
|
||||||
|
|
||||||
|
## Global constraints
|
||||||
|
|
||||||
|
These bind every task below and are not restated in each one.
|
||||||
|
|
||||||
|
- **Two threads only.** `EventDetector::feed()` runs on the producer inside
|
||||||
|
`Acquisition::on_data()`, under `ScopeModel::mutex()`. It allocates nothing, throws nothing,
|
||||||
|
does no I/O, and is `noexcept`. The statistics accumulator and the search driver run on the
|
||||||
|
GTK main thread.
|
||||||
|
- **Every new source file carries the SPDX MIT header**, matching the existing files.
|
||||||
|
- **Stream seconds, end to end.** Nothing on the trigger or search path converts a span to a
|
||||||
|
sample count, and nothing introduces a samples-per-second estimate. That conversion is the
|
||||||
|
documented cause of the "the post window is empty" defect
|
||||||
|
(`docs/claude/invariants-trigger-and-pinning.md`).
|
||||||
|
- **The detector is fed the stored (decimated, averaged) value**, never the wire value — an
|
||||||
|
event found on a sample the buffer does not hold cannot be cut from it.
|
||||||
|
- **A pinned axis is pinned from the snapshot's own stamps, never from the request.** Applies to
|
||||||
|
every pause the search publishes.
|
||||||
|
- **There is no unit test suite.** Every assertion is a probe line on stderr, driven through the
|
||||||
|
real widgets, read against the documented baselines: the documented build-warning count and
|
||||||
|
zero `Gtk-CRITICAL`.
|
||||||
|
- **No figure is written into a document unless it was measured on this machine.** Costs are
|
||||||
|
printed by probes and quoted from those runs.
|
||||||
|
- **Run one probe at a time.** The only exception is the search probe's internal
|
||||||
|
scan-versus-armed pairing, which is one probe.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
| File | Responsibility |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/condition.hpp` / `.cpp` | **New.** `EventCondition`, `TriggerType`, `WidthCmp`, and `EventDetector` — the whole per-sample condition state machine and nothing else. No model, no GTK, no allocation. |
|
||||||
|
| `src/trigger.hpp` / `.cpp` | `TriggerConfig` grows an `EventCondition`. `TriggerEngine` keeps arming, the hold-off, the pre-window gate, the post window and the mode, and delegates detection to `EventDetector`. |
|
||||||
|
| `src/search.hpp` / `.cpp` | **New.** `BufferSearch` — the chunked scan driver over the retained ring, the hit list, the cursor into it, and the staleness state. Owns no widgets. |
|
||||||
|
| `src/measure.hpp` / `.cpp` | `MeasureStatsAccum` (Welford) beside the existing result types. |
|
||||||
|
| `src/plot_host.hpp` / `.cpp` | `kSearchMarks` cursors per row, created in `install()`, placed by a new `set_search_marks()`. |
|
||||||
|
| `src/ui.cpp` | The trigger panel's type and parameter rows, the `Search` expander, the statistics toggle and second line, the chunk pump in the frame tick, and the four probes. |
|
||||||
|
| `src/layout_store.hpp` / `.cpp` | One new `LayoutState` field: the statistics toggle. The trigger is not persisted today and is not persisted by this design. |
|
||||||
|
| `tools/mock_udpstreamer.py` | `--glitch-at` / `--glitch-width` / `--quiet-at`. |
|
||||||
|
| `README.md`, `docs/claude/*` | What was built, and the invariants it adds. |
|
||||||
|
|
||||||
|
`src/condition.*` and `src/search.*` are new files rather than additions to `src/trigger.cpp`
|
||||||
|
and `src/ui.cpp` for two reasons: the detector must be includable by the search without
|
||||||
|
dragging in the engine's arming state, and `src/ui.cpp` is already the largest file in the
|
||||||
|
project.
|
||||||
|
|
||||||
|
## 1. The shared event condition
|
||||||
|
|
||||||
|
### What is extracted
|
||||||
|
|
||||||
|
`TriggerEngine::process()` (`src/trigger.cpp:38-106`) currently does three jobs. Lines 78-84
|
||||||
|
detect a crossing; line 92 gates it on the pre-trigger window; lines 42-75 run the
|
||||||
|
pending/post-window/hold-off/mode machine. Only the first is what a search means by "a
|
||||||
|
condition", so only the first moves.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
enum class TriggerType { Edge, PulseWidth, Window, Dropout };
|
||||||
|
enum class WidthCmp { Less, Greater, Within, Outside };
|
||||||
|
|
||||||
|
struct EventCondition {
|
||||||
|
TriggerType type = TriggerType::Edge;
|
||||||
|
double level = 0.0;
|
||||||
|
double level2 = 0.0; ///< Window: the band's other edge. Ignored otherwise.
|
||||||
|
TriggerEdge edge = TriggerEdge::Rising;
|
||||||
|
WidthCmp cmp = WidthCmp::Greater; ///< PulseWidth only.
|
||||||
|
double width_s = 1.0e-3; ///< PulseWidth only.
|
||||||
|
double width2_s = 2.0e-3; ///< PulseWidth, Within/Outside only.
|
||||||
|
double timeout_s = 1.0e-3; ///< Dropout only.
|
||||||
|
bool operator==(const EventCondition&) const noexcept;
|
||||||
|
};
|
||||||
|
|
||||||
|
class EventDetector {
|
||||||
|
public:
|
||||||
|
/// Adopts the condition. Resets the state iff the condition differs.
|
||||||
|
void configure(const EventCondition& c) noexcept;
|
||||||
|
void reset() noexcept;
|
||||||
|
/// One sample, in stream order. Returns the event's own stream time, or NaN for no event.
|
||||||
|
double feed(double value, double t) noexcept;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`feed()` returns a **time** rather than a bool because for two of the four types the event's
|
||||||
|
instant is not the sample being looked at: a pulse width is only known at the pulse's closing
|
||||||
|
edge, and a dropout fires on an ordinary sample nowhere near an edge.
|
||||||
|
|
||||||
|
`TriggerEngine::process()` then reads:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const double event_t = detector_.feed(value, now_s);
|
||||||
|
if (!std::isfinite(event_t)) return false;
|
||||||
|
if (event_t - oldest_s_ < pre_seconds_) return false; // the gate, now on the event's own t
|
||||||
|
last_trigger_s_ = event_t;
|
||||||
|
pending_ = true;
|
||||||
|
pending_start_s_ = event_t;
|
||||||
|
```
|
||||||
|
|
||||||
|
Note the gate and `pending_start_s_` both move from `now_s` to `event_t`. For `Edge` they are
|
||||||
|
the same value and the behaviour is bit-identical to today; for a long qualifying pulse they
|
||||||
|
differ, and using `now_s` would measure the post window from the wrong instant.
|
||||||
|
|
||||||
|
`TriggerEngine::configure()` keeps its name-keyed reset exactly as it is
|
||||||
|
(`docs/claude/invariants-trigger-and-pinning.md`) and additionally calls
|
||||||
|
`detector_.configure(cfg.condition)`, which resets the detector only when the condition
|
||||||
|
actually changed — a condition re-applied every packet must not restart the state machine, or a
|
||||||
|
pulse-width condition could never see a pulse end.
|
||||||
|
|
||||||
|
### The four types
|
||||||
|
|
||||||
|
| Type | Fires when | The instant it calls t0 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Edge` | `level` is crossed in `edge`'s direction | the crossing sample |
|
||||||
|
| `PulseWidth` | a pulse — a crossing in `edge`'s direction, then the crossing back — whose duration satisfies `cmp` against `width_s` (and `width2_s` for `Within`/`Outside`) | **the closing edge**: the width is not known before it |
|
||||||
|
| `Window` | the signal enters or leaves `[min(level,level2), max(level,level2)]`; `Rising` = enters, `Falling` = leaves, `Both` = either | the crossing sample |
|
||||||
|
| `Dropout` | `level` has not been crossed for longer than `timeout_s` | the first sample at which the timeout is observed |
|
||||||
|
|
||||||
|
**Glitch is `PulseWidth` with `cmp = Less`** and is not a separate type. That is how bench
|
||||||
|
scopes spell it, and one state machine cannot disagree with itself about where a pulse ended.
|
||||||
|
The panel may label the preset "glitch".
|
||||||
|
|
||||||
|
`Dropout`'s t0 is the sample at which the expiry is *observed*, not the exact expiry instant,
|
||||||
|
for the same reason every readout in this app quotes a stored sample: t0 has to be a sample the
|
||||||
|
buffer holds, because the cut is a binary search over the time ring and the axis is pinned to
|
||||||
|
what the cut got.
|
||||||
|
|
||||||
|
**A zero-width band is refused by the panel, not special-cased in the detector.** The band test
|
||||||
|
is `v >= lo && v <= hi` and the event is a change of side, so with `level == level2` the signal
|
||||||
|
is "inside" only on exact float equality — which a crossing essentially never lands on, making
|
||||||
|
the condition one that silently never fires. That is a worse failure than a refusal, and it
|
||||||
|
cannot be fixed by special-casing the detector into an edge trigger: the two state machines
|
||||||
|
answer different questions and conflating them is the divergence this design exists to prevent.
|
||||||
|
So the second level spin is clamped to differ from the first by at least its own increment, the
|
||||||
|
panel says why, and `EventDetector` keeps one unconditional band test.
|
||||||
|
|
||||||
|
### Panel and session
|
||||||
|
|
||||||
|
The trigger panel gains a **type** combo above the existing rows, and the rows below it change
|
||||||
|
with the type: `Edge` shows the level, edge and pre-% rows it shows now; `PulseWidth` adds a
|
||||||
|
comparison combo and one or two width spins; `Window` turns the single level spin into two;
|
||||||
|
`Dropout` replaces the width with a timeout. Hold-off, pre-% and Normal/Single are common to
|
||||||
|
all four and do not move.
|
||||||
|
|
||||||
|
No plot instrument is added. The trigger level has never been drawn on the plot — it is a spin
|
||||||
|
button (`src/ui.cpp:135`, `:1604`) — so a band needs no second ruler.
|
||||||
|
|
||||||
|
`TriggerConfig` gains one `EventCondition` member; its `level` and `edge` replace the
|
||||||
|
`TriggerConfig` fields of those names, and every reader moves with them.
|
||||||
|
|
||||||
|
**The condition is not persisted, because the trigger is not persisted.** `LayoutState`
|
||||||
|
(`src/layout_store.hpp:19-63`) carries the window, rows, calibration, colours, enabled signals,
|
||||||
|
the source, the math graphs and the measurements — and no trigger field at all; the trigger is
|
||||||
|
built from `--holdoff` and the panel's own defaults on every launch. Adding persistence for it
|
||||||
|
is a separate feature nobody asked for, and a session that silently restored an armed
|
||||||
|
pulse-width condition would be a surprise rather than a convenience. The only session key this
|
||||||
|
design adds is the measurements pane's **statistics toggle**, which belongs beside the
|
||||||
|
`measures` it governs.
|
||||||
|
|
||||||
|
The level's existing default — the middle of the signal's declared range until the user edits it
|
||||||
|
(`src/ui.cpp:2016-2054`) — applies to `Window` as a band about that middle, and `level2`
|
||||||
|
defaults with it.
|
||||||
|
|
||||||
|
## 2. Measurement statistics
|
||||||
|
|
||||||
|
### What is accumulated
|
||||||
|
|
||||||
|
Not the samples: `Average` is already the mean of every visible sample. A statistic is of the
|
||||||
|
**measurement's own reported value over time** — count, mean, standard deviation, min and max of
|
||||||
|
every value that measurement has produced since the last reset.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// The variation of one measurement's own value over time. Fixed size, no allocation: it is
|
||||||
|
/// folded in the pane's existing recompute pass, which must not grow a second pass.
|
||||||
|
struct MeasureStatsAccum {
|
||||||
|
std::uint64_t n = 0;
|
||||||
|
double mean = 0.0; ///< Welford's running mean
|
||||||
|
double m2 = 0.0; ///< Welford's sum of squared deviations
|
||||||
|
double lo = 0.0;
|
||||||
|
double hi = 0.0;
|
||||||
|
void add(double v) noexcept;
|
||||||
|
void reset() noexcept;
|
||||||
|
double sd() const noexcept; ///< sqrt(m2 / (n - 1)), 0 for n < 2
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Welford rather than `sum` and `sum_of_squares`: on a value with a large offset — a 3.3 V logic
|
||||||
|
level whose σ is a few millivolts — the naive form loses the variance entirely to cancellation
|
||||||
|
in `Σv²`.
|
||||||
|
|
||||||
|
### One observation per recompute
|
||||||
|
|
||||||
|
`update_measures()` is throttled deliberately (`src/ui.cpp:3141-3162`): at most one pass per
|
||||||
|
`kMeasureRefreshUs` while the window scrolls, four times that when a pass is heavy, and exactly
|
||||||
|
**once when the window settles** — the `!changed` early return at `:3153` is what makes a pinned
|
||||||
|
window recompute once rather than every frame. The accumulator takes one observation per pass,
|
||||||
|
so:
|
||||||
|
|
||||||
|
- A scrolling live view contributes on the order of ten observations a second.
|
||||||
|
- A paused view, a held event or a pinned window contributes **one**.
|
||||||
|
- `n` is therefore a count of *recomputes* — not of samples, frames or seconds. The pane labels
|
||||||
|
it `n` and its tooltip says so. Presenting it as a sample count would be a figure the user
|
||||||
|
acts on and that is false.
|
||||||
|
|
||||||
|
Two exclusions, each of which would corrupt the statistic silently:
|
||||||
|
|
||||||
|
- An **invalid** result (`--`) contributes nothing, to neither `n` nor the extremes.
|
||||||
|
- The **`measure_last_valid` fallback** (`src/ui.cpp:3363`) is **not** accumulated. It exists so
|
||||||
|
a level mark does not blink off and is by construction a repeat of an observation already
|
||||||
|
counted; folding it in pulls the mean toward whatever preceded a dropout and collapses σ.
|
||||||
|
|
||||||
|
### Reset
|
||||||
|
|
||||||
|
An explicit **Reset** button in the pane clears every accumulator. Three changes clear them
|
||||||
|
implicitly, because each one makes the values before and after a different population:
|
||||||
|
|
||||||
|
- the measurement is edited (signal, type or options);
|
||||||
|
- a CONFIG arrives — the signal list is rebuilt and a name may now resolve elsewhere;
|
||||||
|
- the display window changes — a mean over 2 s and a mean over 600 s are different statistics.
|
||||||
|
|
||||||
|
Deliberately **not** cleared by a pause, a resume, an event latch, or a pan/zoom within the
|
||||||
|
window. Each of those contributes its one settled observation.
|
||||||
|
|
||||||
|
Accumulators are not persisted. A statistic over a session that has ended is not a statistic;
|
||||||
|
the **toggle's state is** persisted.
|
||||||
|
|
||||||
|
### Display
|
||||||
|
|
||||||
|
One `Statistics` check button at the top of the measurements pane. Off — the default — the pane
|
||||||
|
is exactly as it is now. On, every row grows a second line beneath it, dimmed and smaller:
|
||||||
|
|
||||||
|
```
|
||||||
|
● ● frequency 5.0003 Hz edit
|
||||||
|
x̄ 5.0001 σ 0.0004 ▾ 4.9989 ▴ 5.0012 n 143
|
||||||
|
```
|
||||||
|
|
||||||
|
Pane-wide rather than per-measurement, so that the second lines align and are comparable, and
|
||||||
|
because the per-measurement dialog is already the busiest surface in the app.
|
||||||
|
|
||||||
|
The second line is a `GtkLabel` in a vertical box wrapping the existing horizontal row, built in
|
||||||
|
`measures_row()` (`src/ui.cpp:2495`) and shown or hidden by the toggle — the toggle must **not**
|
||||||
|
call `rebuild_measures_pane()`, which drops and recreates every label including the ones the
|
||||||
|
frame tick holds pointers to. All five numbers go through the same `format_measure()` the
|
||||||
|
value above them uses, so units and significant figures agree by construction.
|
||||||
|
|
||||||
|
## 3. Search and navigate
|
||||||
|
|
||||||
|
### What it searches, and with what
|
||||||
|
|
||||||
|
A `Search` expander at the bottom of the trigger panel, searching **the trigger's own
|
||||||
|
`EventCondition`** read straight off `TriggerConfig`. There is no second condition UI: a
|
||||||
|
condition is set up once and can either be armed to capture live or scanned for over the buffer
|
||||||
|
already held. That is what the shared evaluator buys, and it gives the search all four types for
|
||||||
|
free.
|
||||||
|
|
||||||
|
It runs on the **trigger's channel** over the **retained ring** — not the visible window, which
|
||||||
|
the user can already read by looking.
|
||||||
|
|
||||||
|
### The scan is exact, chunked, and progressive
|
||||||
|
|
||||||
|
It feeds the same `EventDetector`, sample by sample, in stream order, raw. No bucket-skipping: a
|
||||||
|
level-0 bucket whose `[lo,hi]` does not straddle the level cannot hold a crossing, so skipping
|
||||||
|
it is tempting, but the detector is a sequential state machine and synthesising its previous
|
||||||
|
value across a skipped span is precisely how the search and the live trigger would come to
|
||||||
|
disagree about where an edge was. The shared evaluator exists to make that impossible.
|
||||||
|
|
||||||
|
A raw pass over the whole ring cannot be one pass under one lock — that holds
|
||||||
|
`ScopeModel::mutex()` long enough to starve the producer. `BufferSearch` therefore advances in
|
||||||
|
**bounded chunks pumped from the frame tick**, taking and releasing the lock per chunk, the same
|
||||||
|
shape `update_measures()` uses to materialise its window in chunks. The detector's state carries
|
||||||
|
across chunks because it is sequential. The panel shows progress and a Cancel.
|
||||||
|
|
||||||
|
Two limits, both reported in the panel rather than hidden:
|
||||||
|
|
||||||
|
- **The ring keeps filling during the scan** — a pause freezes the view, not the acquisition.
|
||||||
|
Each chunk re-clamps to the ring's current base; if the base has passed the chunk's start the
|
||||||
|
scan stops and the panel says so (`searched 412 s of 600 s — the oldest data was overwritten
|
||||||
|
while scanning`). Reachable only at high rates on a small buffer.
|
||||||
|
- **The hit list is capped** at `kSearchHitsMax = 10000`, and the panel says
|
||||||
|
`stopped at 10000 matches`. A condition matching every sample is a mis-set condition; the
|
||||||
|
answer is a message, not millions of doubles.
|
||||||
|
|
||||||
|
### The hits, and navigating them
|
||||||
|
|
||||||
|
Hits are a `std::vector<double>` of stream times, each the t0 its condition defines. The panel
|
||||||
|
shows `147 matches` with `◀ prev`, `next ▶` and `3 / 147`, with key bindings while the panel has
|
||||||
|
focus.
|
||||||
|
|
||||||
|
Navigation reuses the existing pause. Jumping to hit `t` pauses if not already paused and calls
|
||||||
|
the primitive that is already there (`src/ui.cpp:2188-2199`):
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
model->publish_pause_view(t - w / 2.0, t + w / 2.0, /*x_offset=*/t);
|
||||||
|
if (auto snap = model->pause_view(); snap && !snap->stamps.empty())
|
||||||
|
rows->pin_pause_window(snap->stamps.front(), snap->stamps.back());
|
||||||
|
```
|
||||||
|
|
||||||
|
`w` is the current display window. `x_offset = t` puts the hit at **x = 0**, exactly where a
|
||||||
|
held event's edge sits, so the two ways of looking at an event read alike and the x-label logic
|
||||||
|
needs no new case. The pin comes off the snapshot's own stamps and never off the request: a hit
|
||||||
|
near the oldest retained sample yields a shorter cut, and pinning the request leaves an empty
|
||||||
|
left margin that a zoom-in walks into.
|
||||||
|
|
||||||
|
Resuming or clearing the search resumes and unpins exactly as the pause button does. The hit
|
||||||
|
list survives a resume — it describes data the ring may still hold — but goes stale, so the
|
||||||
|
panel marks its age, and any structural rebuild (a CONFIG, an enable/disable, a calibration
|
||||||
|
change, or a window change that moves `time_stride`) clears it outright, because those drop the
|
||||||
|
history it refers to.
|
||||||
|
|
||||||
|
### Marks
|
||||||
|
|
||||||
|
Cursors are not dynamic: `RowSet` creates them up front in fixed arrays
|
||||||
|
(`kMeasureMarks = 6`, `src/plot_host.hpp:55`) because a `Plot` cannot give one up. So this adds
|
||||||
|
`kSearchMarks = 16` cursors per row, created in the same `install()` loop
|
||||||
|
(`src/plot_host.cpp:185-188`) and taking the same read-only dashed 0.75 px stroke the measure
|
||||||
|
marks take, parked when unused.
|
||||||
|
|
||||||
|
Every frame, the hits inside the visible window are placed on them, nearest to the view's centre
|
||||||
|
first, with the current hit in a distinct colour. When more hits are in view than there are
|
||||||
|
marks the panel reads `16 of 38 marks shown — zoom in to see the rest`, which is a true
|
||||||
|
statement about the picture rather than a silent truncation. Like the measure marks they are
|
||||||
|
re-placed every frame, which is what makes them read-only: a gesture moves one and this puts it
|
||||||
|
back.
|
||||||
|
|
||||||
|
## 4. Verification
|
||||||
|
|
||||||
|
Four probes, each scheduled by an env var, each driving the real widgets, each printing tagged
|
||||||
|
lines to stderr. Added to `docs/claude/verification.md` and `docs/claude/probe-readings.md` with
|
||||||
|
the readings a correct run gives — measured, never assumed.
|
||||||
|
|
||||||
|
**The numbers in the sample lines below, and in the pane mock-up above, show the line's *shape*
|
||||||
|
only.** None of them has been measured; nothing may copy them into a document as a reading. The
|
||||||
|
figures that go into `verification.md` come from a run.
|
||||||
|
|
||||||
|
**`UDPSCOPE_PROBE_CONDITION_MS`** — for each of `Edge`, `PulseWidth` with `Less`, `PulseWidth`
|
||||||
|
with `Greater`, `Window` and `Dropout`: set the type and its parameters through the panel, arm,
|
||||||
|
wait for the latch, print
|
||||||
|
|
||||||
|
```
|
||||||
|
[condition-probe] type=pulsewidth cmp=less width=0.0005 t0=4.3021 want=4.3020 ok=1
|
||||||
|
```
|
||||||
|
|
||||||
|
`want` comes from the mock's injection schedule. `ok=1` is the reading.
|
||||||
|
|
||||||
|
**`UDPSCOPE_PROBE_STATS_MS`** — add a `frequency` measurement on the mock's sine, turn the
|
||||||
|
statistics toggle on, let several recomputes land, print
|
||||||
|
|
||||||
|
```
|
||||||
|
[stats-probe] n=14 mean=5.0001 sd=0.0004 lo=4.9989 hi=5.0012 toggle=1 line2=1
|
||||||
|
```
|
||||||
|
|
||||||
|
then press Reset and print it again: `n=0` is the assertion. It must also hold a pause for a
|
||||||
|
stretch and show `n` advancing by **exactly 1** across it — that is what distinguishes
|
||||||
|
accumulating per recompute from accumulating per frame. With the `measure_last_valid` fallback
|
||||||
|
wrongly accumulated, `n` climbs through an injected dropout while the value does not change and
|
||||||
|
`sd` collapses; that is the second defect this probe exists for.
|
||||||
|
|
||||||
|
**`UDPSCOPE_PROBE_SEARCH_MS`** — configure a glitch condition, scan to completion against the
|
||||||
|
injected pulse train, print
|
||||||
|
|
||||||
|
```
|
||||||
|
[search-probe] matches=7 first=1.3020 last=6.8020 scanned=8.21/8.21 s truncated=0 capped=0
|
||||||
|
[search-probe] jump 1/7 t=1.3020 axis=[-1.0000..1.0000] snap=[-1.0000..1.0000] inside=1 x0_hit=1
|
||||||
|
```
|
||||||
|
|
||||||
|
for three successive `next` presses. `matches` is asserted against the injection count,
|
||||||
|
`inside=1` is the pause-pin invariant, `x0_hit=1` is the hit sitting at x = 0.
|
||||||
|
|
||||||
|
**The cross-check that makes the shared evaluator a claim and not a hope.** The same probe run,
|
||||||
|
same condition, same mock, **armed** instead of searched: the first latch's t0 must equal the
|
||||||
|
scan's `first` exactly.
|
||||||
|
|
||||||
|
```
|
||||||
|
[search-probe] armed t0=1.3020 vs scanned first=1.3020 agree=1
|
||||||
|
```
|
||||||
|
|
||||||
|
A divergence between the live and the offline path appears here and nowhere else.
|
||||||
|
|
||||||
|
**Costs are printed, not asserted**, because an unmeasured figure drifts: `scan cost: N chunks,
|
||||||
|
X ms total, worst chunk Y ms` — the **worst chunk** is the invariant worth watching, since it is
|
||||||
|
what the producer waited on; the total is allowed to be long. Statistics accumulation is folded
|
||||||
|
into the existing `measure cost:` line.
|
||||||
|
|
||||||
|
**Mock additions:** `--glitch-at <s>` / `--glitch-width <s>` inject a narrow pulse into a pulse
|
||||||
|
train at a known instant, and `--quiet-at <s>` stops a signal crossing its level from a known
|
||||||
|
instant. Both are permanent regression cases, like `--time-glitch-at`.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **`TriggerConfig`'s `level`/`edge` move into `EventCondition`**, so every reader changes —
|
||||||
|
the panel, the probes and the status line. Mechanical, but wide, and the
|
||||||
|
mitigation is that `Edge` behaviour must be bit-identical afterwards: the existing trigger
|
||||||
|
probes are the regression test and are run before anything new is added.
|
||||||
|
- **The scan's worst chunk is a latency the producer pays.** The chunk size is the one knob, it
|
||||||
|
is picked from the measured figure, and the probe prints the worst chunk every run.
|
||||||
|
- **`kSearchMarks = 16` is a fixed cost in cursors per row** on every plot whether or not a
|
||||||
|
search is running, as `kMeasureMarks = 6` already is.
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# A late packet leaves a hole it could have filled, and the clock it would be filed under is the wrong one
|
||||||
|
|
||||||
|
Two requests, and they are one design.
|
||||||
|
|
||||||
|
The first is that a reordered packet currently reads as permanent damage. `udps_client.c:504-514`
|
||||||
|
orders the stream by the wrapping `uint32` counter and discards anything that arrives behind the
|
||||||
|
newest counter — `stats.stale_packets++; return 0` — *before* spending anything on the payload.
|
||||||
|
The samples are gone, the span they should have filled stays empty, and udpscope never learns
|
||||||
|
there was a span at all. The asked-for remedy is to leave a hole of
|
||||||
|
`<N samples> × <LAST_PACKET_ID − NEW_PACKET_ID − 1>`, mark it invalid, and fill it when the
|
||||||
|
missing packets arrive.
|
||||||
|
|
||||||
|
The second is `todo.md` item 2: a relative-time mode in which the plot's x comes only from the
|
||||||
|
declared sample rate and the packet number, with absolute stream time kept for export.
|
||||||
|
|
||||||
|
They are one design because **the counter is what makes a hole addressable**. A reserved hole has
|
||||||
|
to be a known number of samples at a known position, and a late packet has to compute which
|
||||||
|
position it belongs to. Absolute stamps cannot do either — a lost packet's stamps were never
|
||||||
|
received, so its extent in time is a guess, and the guess is what the glitch bridge at
|
||||||
|
`src/acquisition.cpp:274-292` is already making. The counter gives the extent exactly. Relative
|
||||||
|
timing is not a cosmetic preference that happens to be requested at the same time; it is the
|
||||||
|
coordinate system in which resequencing is arithmetic rather than inference.
|
||||||
|
|
||||||
|
Two facts from the existing code shape almost every decision below, and both say the work is
|
||||||
|
smaller than it looks:
|
||||||
|
|
||||||
|
- **`frame->counter` and `frame->lost` are read nowhere in udpscope.** `frame.lost` is set by the
|
||||||
|
client at `udps_client.c:560` and appears in `src/acquisition.cpp` only inside comments
|
||||||
|
(`:309`, `:331`, `:336`). The client has been reporting the exact gap width for the life of the
|
||||||
|
project and nothing has ever consumed it. Neither input needs inventing.
|
||||||
|
- **`clamp_deadline()` already exists** (`udps_client.c:1102`), documented as *"Clamps `budget` so
|
||||||
|
a pending deadline is not slept through"* and already used for the keepalive and the silence
|
||||||
|
timeout. The reorder deadline is a third call to it.
|
||||||
|
|
||||||
|
## What was decided, and the constraint that decided it
|
||||||
|
|
||||||
|
The latency budget is **20 ms**, maximum. That one number settles the shape of the whole receive
|
||||||
|
path, because it is a *time* budget and not a packet count: at 1000 pkt/s it is twenty packet
|
||||||
|
spans of reorder depth, at 10 pkt/s it is a fifth of one. A hold policy counted in packets would
|
||||||
|
either be useless at high rates or add 400 ms at low ones. A policy counted in wall time works at
|
||||||
|
every rate, because network reorder is measured in milliseconds regardless of how fast the
|
||||||
|
producer sends.
|
||||||
|
|
||||||
|
It also means a *contiguous* arrival must be released immediately. The 20 ms is spent only when
|
||||||
|
there is a gap to wait on, never on the common case.
|
||||||
|
|
||||||
|
Past the window, a committed hole is still **backfilled for values only**. Math channels and the
|
||||||
|
trigger are not replayed. A math channel takes NaN in and gives NaN out, so after a repair the
|
||||||
|
raw trace is whole and the math trace derived from it keeps its gap. That is a deliberate,
|
||||||
|
documented state, not an oversight: the display then says *"this region arrived too late to be
|
||||||
|
part of the computation"*, which is true and is cheaper than any mechanism that could make it
|
||||||
|
false.
|
||||||
|
|
||||||
|
Resequencing and hole reservation run in **both** time modes. In relative mode a hole's stamps
|
||||||
|
are exact; in stream mode they are the declared cadence continued from the last good stamp, which
|
||||||
|
is the existing bridge mechanism reused. Relative timing is therefore an independent toggle a user
|
||||||
|
flips for jitter, not a prerequisite for recovery.
|
||||||
|
|
||||||
|
A hole is surfaced two ways: **status-bar counters** and a **shaded band** over the invalid span.
|
||||||
|
A NaN run already breaks the polyline, but a one-packet hole at a 10 s window is sub-pixel, and a
|
||||||
|
gap nobody can see is not a report.
|
||||||
|
|
||||||
|
## Relative timing
|
||||||
|
|
||||||
|
`build_stamps()` today picks its clock *by inspection*: `time_source_` latches 1 for the declared
|
||||||
|
time signal or 2 for the arrival clock and never flips back (`src/acquisition.cpp:357-367`).
|
||||||
|
Relative timing is not a third thing to infer. It is a user-selected mode that bypasses the
|
||||||
|
inference: `--relative-time` on the command line, a toggle in the UI, persisted in the session
|
||||||
|
file.
|
||||||
|
|
||||||
|
### The stamp
|
||||||
|
|
||||||
|
```
|
||||||
|
stamps_[k] = double(slot_base_ + k) / rate
|
||||||
|
```
|
||||||
|
|
||||||
|
`slot_base_` is a running `u64` of samples seen. Each packet the counter reports missing advances
|
||||||
|
it by `batch`, **before** the frame's own samples are stamped; the delivered frame then advances it
|
||||||
|
by its own `n`. The order is what puts the hole ahead of the data that revealed it.
|
||||||
|
|
||||||
|
This is the requested `counter × batch + sample_index` with the origin left at counter zero, but
|
||||||
|
**accumulated rather than multiplied**, and the difference earns three things:
|
||||||
|
|
||||||
|
- `batch` is then only ever needed to size what is *absent*. That is exactly the
|
||||||
|
`<n_samples_per_frame>` of the original formula, and it is the only place the value is load
|
||||||
|
bearing.
|
||||||
|
- The `uint32` wrap costs nothing. The counter is consumed only as a signed delta — the same
|
||||||
|
arithmetic `udps_client.c:505` already performs — so no unwrapping bookkeeping is needed.
|
||||||
|
- A frame whose `n` disagrees with the established `batch` cannot silently shift the whole
|
||||||
|
timeline, because the timeline is built from the `n` values actually seen.
|
||||||
|
|
||||||
|
`batch` is established from the first DATA frame and held for the life of the stream. A later
|
||||||
|
frame disagreeing is a genuine inconsistency in the producer, not a loss, so it warns once rather
|
||||||
|
than re-deriving.
|
||||||
|
|
||||||
|
### The rate is the declared one, and only the declared one
|
||||||
|
|
||||||
|
`build_stamps()` currently overrides a declared cadence with the arrival-measured one when the two
|
||||||
|
disagree by more than ±25% (`src/acquisition.cpp:152-158`), because a CONFIG declaring 1 MS/s
|
||||||
|
while delivering a hundred samples every fifth of a second reads as a staircase in the x column.
|
||||||
|
Relative mode must **not** do this. Discarding arrival timing is the entire point of the mode. The
|
||||||
|
override stays live in stream mode and is skipped in relative mode.
|
||||||
|
|
||||||
|
### The constant offset is already absorbed
|
||||||
|
|
||||||
|
`src/acquisition.cpp:555` stamps `rel = stamps_[k] - epoch`, and the `t0 =` caption names the
|
||||||
|
epoch. A counter that happens to read 500 000 on connect therefore shows up as an epoch value, not
|
||||||
|
as a 50 000 s axis. No origin subtraction is needed in the formula itself.
|
||||||
|
|
||||||
|
### The x axis needs no change at all
|
||||||
|
|
||||||
|
`RowSet::update_time_labels()` already goes to a relative label form on the tick step against the
|
||||||
|
label resolution at the axis' magnitude, with the `t0 =` caption naming the absolute anchor
|
||||||
|
(`docs/claude/invariants-time-axis-and-window.md:34`). "Counting backwards, like it is now" is
|
||||||
|
what that already does. Relative mode changes *where a stamp comes from* and nothing in the label
|
||||||
|
layer.
|
||||||
|
|
||||||
|
### What relative mode makes unreachable
|
||||||
|
|
||||||
|
The degeneracy guard, the implausible-jump test, `kFailureLimit`, the bridge and the monotonic
|
||||||
|
clamp are all dead in relative mode: the stamps are monotone by construction, so there is no bad
|
||||||
|
stamp to detect and nothing to clamp. (`kGlitchPackets` itself survives — not as the threshold of
|
||||||
|
that test, which is unreachable, but as the reservation cap below, where its value and its
|
||||||
|
reasoning carry over unchanged.)
|
||||||
|
|
||||||
|
That is not tidiness, it is a fix. Measured last session: a loss burst past `kGlitchPackets` (256)
|
||||||
|
is misread as a producer clock glitch; each bridge then advances the anchor by *one* packet span
|
||||||
|
while the true timeline is 300 ahead, so the next four packets fail too, `kFailureLimit` trips,
|
||||||
|
and the whole stream is thrown onto the arrival clock — the exact failure the bridge was added to
|
||||||
|
prevent (`--drop-burst 300` reads
|
||||||
|
`jumped implausibly far ahead ... (1.299..1.29999 against a previous 0.99899)` then
|
||||||
|
`timing samples by arrival instead`). In relative mode a 300-packet gap is a known quantity and
|
||||||
|
cannot be misdiagnosed.
|
||||||
|
|
||||||
|
### Absolute time for export
|
||||||
|
|
||||||
|
`todo.md` requires absolute stream time to remain available for export. There is exactly one f64
|
||||||
|
time ring per `BufferStore`, shared by every channel of that buffer, and relative mode takes it.
|
||||||
|
Export therefore needs a **second** f64 ring carrying the absolute stamp under the same group-mean
|
||||||
|
rule, written in **relative mode only** and read by export and by nothing else — eight bytes per
|
||||||
|
stored slot. The absolute ring is not kept unconditionally; it is budgeted along with the main
|
||||||
|
ring and the value rings, which is why it halves the retained span rather than costing a
|
||||||
|
proportional fraction (see SPECS.md for the measured figures and the power-of-two explanation).
|
||||||
|
|
||||||
|
This cost is paid rather than avoided. Without it, selecting relative mode silently degrades the
|
||||||
|
one output `todo.md` says must keep absolute time.
|
||||||
|
|
||||||
|
The measured history cost: **83.886 s** in stream mode and **41.943 s** in relative mode, at
|
||||||
|
100 kS/s, 4 channels, `--buffer-mb 200`. The plan's ~22% figure and the per-byte 75% figure are
|
||||||
|
both wrong because `Partition::time_capacity` is floored to a power of two.
|
||||||
|
|
||||||
|
## The receive path
|
||||||
|
|
||||||
|
### In the client: a reorder ring of reassembled payloads
|
||||||
|
|
||||||
|
Every `udps_frame_t` sets `frame.signals = c->sigs; frame.values = c->vals`
|
||||||
|
(`udps_client.c:566-567`) — one client-owned decode buffer, reused for every packet. A frame is
|
||||||
|
therefore valid only inside the callback, and holding one requires a copy *wherever* the holding
|
||||||
|
happens. The client has no advantage there.
|
||||||
|
|
||||||
|
It has a different one: it still holds the **raw datagram**. Resequencing undecoded wire bytes is
|
||||||
|
strictly cheaper than resequencing decoded doubles — an `f32` or `i16` payload is two to four
|
||||||
|
times smaller than what `c->valbuf` would hold — and the bytes exist nowhere else. That is why
|
||||||
|
this sits in the client and not in `Acquisition`.
|
||||||
|
|
||||||
|
The hook is the `decode_data()` call site at `udps_client.c:599`: after fragment reassembly, which
|
||||||
|
already tolerates arrival order through the slot table, and before decode. Each held entry carries
|
||||||
|
the payload bytes, their length, the counter and `recv_time`.
|
||||||
|
|
||||||
|
The policy, against `next_counter = last_delivered + 1`:
|
||||||
|
|
||||||
|
| arrival | action | added latency |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `== next_counter` | decode and deliver now, then drain held entries that became contiguous | **none** |
|
||||||
|
| `> next_counter` | copy into the ring; set the gap deadline to `now + 20 ms` if not already set | the newer packet waits |
|
||||||
|
| `< next_counter` | the gap is already committed — decode and deliver, flagged **late** | n/a |
|
||||||
|
| deadline fires | give up on the missing counters, advance `next_counter` to the lowest held entry, drain in order | capped at 20 ms |
|
||||||
|
|
||||||
|
The last row is where `stats.stale_packets++; return 0` at `:506-509` is replaced: a late packet
|
||||||
|
is delivered flagged instead of discarded, which is the single change that makes backfill possible
|
||||||
|
at all.
|
||||||
|
|
||||||
|
The deadline is served by a third `clamp_deadline()` call on the `select()` budget
|
||||||
|
(`udps_client.c:1145-1152`). Without it, a held packet on a quiet stream would wait for the next
|
||||||
|
arrival — 100 ms at 10 pkt/s — and blow the budget.
|
||||||
|
|
||||||
|
Two bounds on the ring, because 20 ms is a different number of packets at every rate: a fixed
|
||||||
|
entry count and a fixed byte budget. Overflow commits the gap early rather than growing, and
|
||||||
|
increments its own counter, so an undersized window is visible rather than silent.
|
||||||
|
|
||||||
|
The window itself is `udps_config_t::reorder_window_s`, defaulting to `0.020`, along
|
||||||
|
`recv_buffer_bytes` and `silence_timeout_s` (`udps_client.c:982`). Zero disables resequencing and
|
||||||
|
restores today's discard-on-late behaviour exactly, which is what makes the change bisectable
|
||||||
|
against a reported regression. The policy value is udpscope's; the mechanism is the client's.
|
||||||
|
|
||||||
|
### In `Acquisition`: reservation
|
||||||
|
|
||||||
|
On a frame with `lost > 0`, before pushing the frame's own samples, run `lost × batch` NaN samples
|
||||||
|
through the **existing** per-sample loop at `src/acquisition.cpp:553`.
|
||||||
|
|
||||||
|
That reuse is the design. `tick_` advances, so `tick % g == g - 1` keeps firing on the same phase
|
||||||
|
and the decimation grid does not shift; the group mean still lands on the mean stamp; and
|
||||||
|
`MinMaxPyramid::append` sees NaN, which its existing non-finite contract already handles as
|
||||||
|
*advances the positional count, excluded from every aggregate*. Nothing bespoke is needed for
|
||||||
|
decimation, stamping or the pyramid.
|
||||||
|
|
||||||
|
Stamps across the hole come from the active time source — exact in relative mode, the declared
|
||||||
|
cadence continued from the last good stamp in stream mode, which is the bridge at `:274-292`.
|
||||||
|
|
||||||
|
Math channels take NaN and give NaN, so a math trace keeps its gap. The trigger engine's
|
||||||
|
comparisons are false on NaN, so it cannot latch inside a hole; this is asserted by the probe
|
||||||
|
rather than assumed.
|
||||||
|
|
||||||
|
**Reservation is capped at 256 missing packets**, reusing `kGlitchPackets` and its existing
|
||||||
|
reasoning verbatim — *256 packet spans in one go is an outage, not loss*. Past the cap there is no
|
||||||
|
reservation: log once and continue as today. That cap is also what stops a ten-minute outage from
|
||||||
|
pushing billions of NaN samples through the ring and wiping all history to make room for a gap.
|
||||||
|
|
||||||
|
## Backfill
|
||||||
|
|
||||||
|
`plot::data::RingBuffer` and `MinMaxPyramid` are both strictly append-only today. Backfill needs
|
||||||
|
an amend in each, and the two belong on different threads.
|
||||||
|
|
||||||
|
`plot_host.hpp:183` calls `acquire(ScopeModel&)` from the frame path, so the **pyramid is mutated
|
||||||
|
only on the GTK main thread**, once per frame: the producer pushes into the ring and the main
|
||||||
|
thread folds what was pushed into the pyramid. That split carries straight over to the amend.
|
||||||
|
|
||||||
|
- **Ring amend — producer thread.** `RingBuffer<T>::amend(absolute, v)`, bounds-checked against
|
||||||
|
`[produced_ - capacity, produced_)`, a single relaxed store. The same race class as a push, and
|
||||||
|
an `f32` cannot tear. Measurements and export read the ring directly, so they see a repair
|
||||||
|
immediately.
|
||||||
|
- **Pyramid amend — main thread, inside `acquire()`.** The producer appends amended `(slot, value)`
|
||||||
|
pairs to a plain `std::vector<Amend>` member; `ChannelBuffer::acquire()` drains it **after** its
|
||||||
|
append pass, so every amended index is already published. No SPSC queue is needed: `acquire()` is
|
||||||
|
called under `model.mutex()` and the producer holds that same mutex for all of `on_data`, so the
|
||||||
|
vector is correctly serialised without lock-free machinery.
|
||||||
|
|
||||||
|
The thread split is not a stylistic choice. A bucket is 32 bytes and is not atomic, so amending it
|
||||||
|
from the producer would let a reader see a half-updated bucket whose `sum` does not match its
|
||||||
|
`count` — a wrong mean from a structure whose readers trust `count == 0` to mean *absent*. On the
|
||||||
|
main thread that cannot happen and no seqlock is needed.
|
||||||
|
|
||||||
|
`MinMaxPyramid::amend(index, v)` folds `v` into every covering bucket: at `count == 0`,
|
||||||
|
`min = max = v`, `sum = v`, `sumsq = v²`, `count = 1`; otherwise widen `min`/`max` and accumulate
|
||||||
|
`sum`/`sumsq`, `++count`. Its documented precondition is **non-finite → finite only**, which is
|
||||||
|
what makes it purely additive and O(log n) with no rebuild. An index already dropped by
|
||||||
|
`drop_before` is a no-op.
|
||||||
|
|
||||||
|
### The time ring is never amended
|
||||||
|
|
||||||
|
The hole was pre-stamped at reservation, so a backfill changes values only. `neighbours()` keeps
|
||||||
|
the monotonic array its binary search requires and `window_x0_` stays valid. This is the reason
|
||||||
|
reservation pre-stamps rather than leaving the ring short.
|
||||||
|
|
||||||
|
### Addressing a late packet
|
||||||
|
|
||||||
|
Reservation records `(counter_first, counter_last, tick_start, slot_start, slot_count)` in a
|
||||||
|
bounded side table. A late frame's counter indexes straight into it:
|
||||||
|
|
||||||
|
```
|
||||||
|
tick = tick_start + (counter - counter_first) * batch
|
||||||
|
```
|
||||||
|
|
||||||
|
Only slots lying **wholly** inside the arrived packet's tick range are amended. A slot straddling
|
||||||
|
a hole edge went NaN when its accumulator took a NaN, and its real members were never retained, so
|
||||||
|
it stays NaN. A displayed hole is therefore at most one slot wider per edge than the true loss —
|
||||||
|
invisible at any hole size worth repairing, and it makes a boundary slot need no special case.
|
||||||
|
|
||||||
|
The side table is bounded by the 256-packet reservation cap and a maximum entry count; it is also
|
||||||
|
the source the shaded band reads.
|
||||||
|
|
||||||
|
## Surfacing
|
||||||
|
|
||||||
|
`StreamStats` gains `late_resequenced`, `late_committed` (gave up past 20 ms, or overflowed the
|
||||||
|
reorder ring) and `late_backfilled`, and finally exposes `stale_packets` — which the client has
|
||||||
|
counted since the beginning and which `src/status_text.cpp:70` has never shown, so an inversion
|
||||||
|
today has to be inferred from `packets` and `frames` diverging. All four sit in the status bar's
|
||||||
|
second tier.
|
||||||
|
|
||||||
|
The band is a new `Plot::set_invalid_spans()` in `deps/plotter`, drawn as a tinted quad behind the
|
||||||
|
trace. Unlike the `t0` caption this *is* data-anchored — it must pan and zoom with the data it
|
||||||
|
marks — so it is annotation-class, not chrome. The invariant it must respect is that it reserves
|
||||||
|
no layout space and cannot be picked, so a hole cannot move the layout the labels were measured
|
||||||
|
into.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
udpscope has no unit test suite; the only automated check is the application's own headless
|
||||||
|
self-check (`CLAUDE.md`). This adds exit-dump lines and a `UDPSCOPE_PROBE_RESEQ_MS` probe,
|
||||||
|
asserting: a reorder inside the window is resequenced with no hole; a reorder past the window
|
||||||
|
leaves a hole and then backfills it; the trigger does not latch inside a hole; a 300-packet burst
|
||||||
|
hits the cap and does not throw the stream onto the arrival clock; and relative mode's stamps are
|
||||||
|
monotone with the declared spacing.
|
||||||
|
|
||||||
|
Runs are serial and short, against the two standing baselines: four build warnings on a clean
|
||||||
|
build (two on an incremental build) and zero `Gtk-CRITICAL`. Batching several `xvfb` runs back to
|
||||||
|
back starves the mock past its 2 s `silence_timeout_s` and reads as a reconnect, which is a
|
||||||
|
measurement artefact and not a defect (`docs/claude/verification.md`).
|
||||||
|
|
||||||
|
**One gap in the mock has to be closed first.** `--reorder-every` releases the held packet
|
||||||
|
immediately after the one that overtook it, so it is ~0 ms late and would always land inside the
|
||||||
|
20 ms window. That exercises resequencing but leaves the **backfill path untestable**. The mock
|
||||||
|
needs `--reorder-delay-ms` so a packet can be released deliberately past the window.
|
||||||
|
|
||||||
|
## Documentation this lands against
|
||||||
|
|
||||||
|
- `docs/claude/invariants-data-path.md` — append-only stops being the whole truth; the amend's
|
||||||
|
precondition and its thread belong here.
|
||||||
|
- `docs/claude/invariants-plot-and-layout.md` — the band, and the layout space it must not take.
|
||||||
|
- `docs/claude/invariants-trigger-and-pinning.md` — the trigger does not latch inside a hole.
|
||||||
|
- `docs/claude/verification.md` — the new probe and dump lines, and `--reorder-delay-ms`.
|
||||||
|
- `docs/claude/build-and-run.md` — the vendored `udpsclient` diff: the reorder ring, the late
|
||||||
|
delivery replacing the discard, and the third `clamp_deadline()`.
|
||||||
|
- `README.md` and a `SPECS.md` deviation note for relative-time mode.
|
||||||
|
|
||||||
|
## Deliberately not in scope
|
||||||
|
|
||||||
|
- **Replaying math channels or the trigger over a backfilled region.** Decided against above.
|
||||||
|
- **Interpolating across an unrepaired hole.** A hole is reported, not invented. There is no
|
||||||
|
interpolation anywhere in the data path today and this does not add the first.
|
||||||
|
- **Making relative mode the default.** It is a toggle. The default stays the declared time
|
||||||
|
signal, so no existing session or run changes what it shows.
|
||||||
|
- **The `(min, max, mean, rms)` gather mode** that would fix the separate solid-band degeneration
|
||||||
|
at wide time windows. Related to the pyramid, unrelated to this; still open.
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# A square wave's riser is sometimes drawn and sometimes not, and the trace is thinner than the library's own default
|
||||||
|
|
||||||
|
The report is that a vertical line — the rising or falling edge of `square` in
|
||||||
|
`tools/mock_udpstreamer.py` — is too thin to see, and that it *glitches in and out of
|
||||||
|
existence*. Those are two complaints, not one, and they have different causes. The second is a
|
||||||
|
defect with a derivable mechanism and a single line of code behind it. The first is a styling
|
||||||
|
choice this app made and then never revisited: udpscope asks for a trace **thinner** than
|
||||||
|
`deps/plotter`'s own default.
|
||||||
|
|
||||||
|
The three remedies named in `todo.md` — grade with more strength, thicken the lines, enlarge the
|
||||||
|
sample dots — are all taken. They are not, on their own, enough: the glitch is a row being
|
||||||
|
dropped *before* any of those knobs is consulted, so a thicker, brighter trace would glitch in
|
||||||
|
and out at a thicker, brighter width.
|
||||||
|
|
||||||
|
## The defect: `lit_floor` decides existence, and `intensity_floor` only decides alpha
|
||||||
|
|
||||||
|
`Renderer::stage_trace_density()` (`deps/plotter/src/render/renderer.cpp`) accumulates one
|
||||||
|
column's dwell into `column_rows_`, where **a segment deposits one unit spread over the rows it
|
||||||
|
spans** — `w = 1 / (r1 - r0 + 1)`. It then decides which rows are inked at all:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const f32 lit_floor = 0.01f * peak;
|
||||||
|
...
|
||||||
|
const bool lit = r < rows && cover > lit_floor;
|
||||||
|
```
|
||||||
|
|
||||||
|
`peak` is the column's own maximum cover. That makes the existence test a *share of the
|
||||||
|
brightest row in the same column*, and for a square wave the brightest row in a column is a
|
||||||
|
flat level, whose segments each deposit a full 1.0 into one row.
|
||||||
|
|
||||||
|
Take a column that holds both a flat level and the edge. At ~105 samples a column the gather is
|
||||||
|
on raw samples, so the column holds ~105 segments: all but one are flat and deposit a full 1.0
|
||||||
|
into one row, while the riser is a single segment spread over the whole axis and deposits
|
||||||
|
`1/312 = 0.0032` into each of 312 rows. `peak` is then ~50 and `lit_floor` ~0.5 — **over a
|
||||||
|
hundred times the riser's own deposit**. Every row of the riser is unlit. The connector at the
|
||||||
|
bottom of `emit_column()` does not rescue it either: the top run and the bottom run are both lit,
|
||||||
|
so the column's `band_lo…band_hi` already spans the full height, neither gap test fires, and no
|
||||||
|
connector is staged. The column is drawn as a bright mark at the top and a bright mark at the
|
||||||
|
bottom with nothing between them.
|
||||||
|
|
||||||
|
In the phase where the riser lands in a column *by itself*, `peak` is the riser's own deposit,
|
||||||
|
the floor is a hundredth of it, every row clears it, and the riser is drawn full height. Which
|
||||||
|
case a frame lands in depends on the phase of the bucket grid against the pixel grid, and the
|
||||||
|
trace scrolls, so the phase sweeps. That is the whole of "glitches in and out of existence" — not
|
||||||
|
a redraw race and not a data gap.
|
||||||
|
|
||||||
|
### Measured, before writing any of this down
|
||||||
|
|
||||||
|
A true square wave (period 2098 samples, so ~20 columns a period), 60 000 x-units over the 572 px
|
||||||
|
axis of `deps/plotter`'s own headless EGL harness — 104.8 samples a column — drawn twice, once
|
||||||
|
with `intensity_grading` off and once on, with the pixels read back both times over the **same**
|
||||||
|
axes rect. The reading counts the columns the *ungraded* frame inked as a full-amplitude band
|
||||||
|
(the riser columns) and then asks how many of exactly those rows the graded frame left dark:
|
||||||
|
|
||||||
|
| | riser columns | inked rows over them | intact | gutted | worst dark run | segments |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| ungraded | 116 | 35 884 | — | — | — | 627 |
|
||||||
|
| graded, before | 116 | **2 916** | 10 | **106** | **311 of 312 rows** | 684 |
|
||||||
|
| graded, after | 116 | 35 572 | 115 | 1 | — | 732 |
|
||||||
|
|
||||||
|
So the graded path was drawing **8 %** of the ungraded path's ink over the riser columns, and 106
|
||||||
|
of 116 of them were gutted outright while 10 survived — the 10 being the phase that put a riser
|
||||||
|
in a column of its own, which is the flicker, measured. The fix restores 99.1 % of the ink and
|
||||||
|
costs 48 segments on a 572 px axis.
|
||||||
|
|
||||||
|
Two notes on that table, both of which the committed test must carry rather than smooth over:
|
||||||
|
|
||||||
|
- **The one column still gutted after the fix is column 11**, the second column of the window's
|
||||||
|
first riser. The ungraded path inks *two* full-height columns per riser (its per-column min/max
|
||||||
|
plus the connectivity extension, and the gathered window's slack sample) where the graded path
|
||||||
|
inks one, which is also why 116 riser columns appear for ~58 risers. The graded path is the
|
||||||
|
more precise of the two here; the test must assert against the riser's own column rather than
|
||||||
|
against every column the flat path happened to fill.
|
||||||
|
- **The existing grading test is unchanged by the fix** — `a_dense_trace_can_be_graded_by_dwell`
|
||||||
|
still reads `flat edge=191 middle=191` against `graded edge=191 middle=131` at `seg=8303`, the
|
||||||
|
documented figures to the digit, and all 376 cases pass. The fix only reaches rows that were
|
||||||
|
below the share floor, which is what makes it a correction and not a restyling.
|
||||||
|
|
||||||
|
The app-level A/B this design originally proposed — `UDPSCOPE_NO_PHOSPHOR=1` against the mock's
|
||||||
|
`square` — was dropped in favour of the above: udpscope has no probe that can see a dropped row,
|
||||||
|
so the app-level run could only have shown that the picture differed, while the library harness
|
||||||
|
reads the pixels and names the rows. The mechanism is measured at the layer it lives in.
|
||||||
|
|
||||||
|
`Style::intensity_floor`'s own documentation already promised what `lit_floor` broke:
|
||||||
|
|
||||||
|
> Not 0: a column the signal merely passed through is still a column the signal *reached*, and a
|
||||||
|
> riser dimmed out of existence is a square wave drawn as two disconnected lines.
|
||||||
|
|
||||||
|
`intensity_floor` guards a lit row's **alpha**. Nothing guarded its **existence**, and the two
|
||||||
|
floors were never reconciled.
|
||||||
|
|
||||||
|
### The fix, already applied and measured
|
||||||
|
|
||||||
|
`lit_floor` may not exceed the smallest deposit the column actually made, so that **a row one
|
||||||
|
segment covers is always lit**. The column's minimum `w` is tracked as the deposits are written
|
||||||
|
— one scalar, reset by `emit_column()`, no allocation, one `min` added to the per-segment path —
|
||||||
|
and the floor becomes:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const f32 lit_floor = std::min(0.01f * peak, 0.5f * w_min);
|
||||||
|
```
|
||||||
|
|
||||||
|
The share term stays: it is what stops a row inking on the running-sum reconstruction's residue
|
||||||
|
alone. The second term is what makes the test *about a measurement* rather than about a ratio to
|
||||||
|
an unrelated row. The half is slack against that reconstruction, not a tuning knob.
|
||||||
|
|
||||||
|
This changes no geometry bound — the runs are still maximal stretches of equal tier, O(rows) per
|
||||||
|
column at worst — and it touches neither the gather level, the pyramid, nor
|
||||||
|
`stage_trace_columns()`. It is in the working tree (`deps/plotter/src/render/renderer.cpp`) with
|
||||||
|
the figures above in the comment at its site, which is what Task 2 below is reduced to reviewing.
|
||||||
|
|
||||||
|
## The second defect, found by testing the first: grading was gated on the column reduction
|
||||||
|
|
||||||
|
The `lit_floor` fix was built and run against the real app, and the report back was that the
|
||||||
|
edges are now always visible — and that a new thing is wrong: *"when I zoom in on an edge the
|
||||||
|
brightness increases a lot compared to when I am looking at the whole waveform."* Clarified: the
|
||||||
|
dimming itself is wanted, **a discontinuity should never be drawn as brightly as a flat top**, at
|
||||||
|
any zoom. So the complaint is not about the amount of dimming but about its *inconsistency*.
|
||||||
|
|
||||||
|
Measured in the same harness, the riser's own ink at mid-height (the rows neither flat level can
|
||||||
|
reach) against the flat level's 191, over a zoom sweep from 101 samples a pixel down to 0.01:
|
||||||
|
|
||||||
|
| samples/pixel | 101 | 33.7 | 10.1 | 3.4 | 1.01 | 0.34 | 0.10 |
|
||||||
|
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||||
|
| riser ink, before | 71 | 71 | 71 | 71 | **191** | **191** | **191** |
|
||||||
|
| riser ink, after | 71 | 71 | 71 | 71 | 71 | 71 | 68 |
|
||||||
|
|
||||||
|
A 2.7× jump in a single step, at `dense_threshold_spp = 1.5`. The cause is a coupling, not a
|
||||||
|
constant: grading is staged *inside* `stage_trace_density()`, which only runs when the column
|
||||||
|
reduction runs, and the reduction is gated on two gathered points per pixel column. That gate is
|
||||||
|
a **density condition whose only purpose is removing the moiré beat** between the bucket pitch
|
||||||
|
and the pixel pitch. Dwell has nothing to do with that beat, so zooming past the threshold
|
||||||
|
switched the whole phosphor model off — and switched it off exactly where the user is looking at
|
||||||
|
an edge closely, which is why it was reported as the edge *lighting up*.
|
||||||
|
|
||||||
|
### The fix: grade the polyline by each segment's slope
|
||||||
|
|
||||||
|
`Renderer::stage_trace_slopes()` stages the sparse polyline into the same `intensity_steps`
|
||||||
|
batches, differing from the dense path only where it must:
|
||||||
|
|
||||||
|
- **The measure is `1 / (1 + |Δy_screen|)`** — dwell per row for a single segment. The `1 +` is
|
||||||
|
not a division guard: a flat segment, span 0 and the commonest case in a sparse window, must be
|
||||||
|
the *brightest* tier, and this makes the measure dimensionless and **absolute**. There is no
|
||||||
|
per-column peak down here to normalise against, and that is an improvement — a sparse frame's
|
||||||
|
tiers mean the same thing from frame to frame, so panning does not reshade the trace.
|
||||||
|
- **No gather mode is excluded.** `grade` is not reused as the gate: it requires `Envelope`,
|
||||||
|
because turning a bucket's min/max into a band answers a different question from the one a
|
||||||
|
`Nearest` or `Mean` host asked. A segment's slope is a property of the polyline itself.
|
||||||
|
- **The geometry is bit-for-bit `stage_xy()`'s**, same `snap = false`; only the batch differs. A
|
||||||
|
segment with one non-finite end is dropped rather than clamped, which is the conclusion
|
||||||
|
`add_polyline_xy()` reaches too.
|
||||||
|
- **`FrameStats::slope_traces` is disjoint from `column_traces`**, so a host asserting
|
||||||
|
`graded_traces == column_traces` keeps that reading unchanged.
|
||||||
|
|
||||||
|
Tier distribution sanity-checked by hand against the defaults (4 steps, gamma 0.45, floor 0.3):
|
||||||
|
span 0 → tier 3, span 1 → 2, span 3 → 2, span 10 → 1, span 312 → 0. A gently sloping sparse sine
|
||||||
|
therefore stays near the top tier and is not dimmed into invisibility, which was the risk.
|
||||||
|
|
||||||
|
### One false alarm, recorded because the metric was the bug
|
||||||
|
|
||||||
|
A first reading of this sweep showed the *frame's brightest pixel* falling from 191 to 167–172 on
|
||||||
|
the sparse path whenever grading was on, which the arithmetic said was impossible (a flat segment
|
||||||
|
is tier 3, alpha exactly 1.0). It was the measurement: the y range was −1…1, so both flat levels
|
||||||
|
sat **on** the boundary rows and were half clipped, and the 191 the ungraded frame reported came
|
||||||
|
from pixels near the riser where two segments overlap and double-composite — which grading splits
|
||||||
|
across tiers, so they no longer saturate. With the range at −2…2 and the level read well clear of
|
||||||
|
the riser, the level is 191 with grading on or off at every zoom. No regression existed. The
|
||||||
|
committed test uses −2…2 and reads the level and the riser as two separate figures for this
|
||||||
|
reason, stated at the site.
|
||||||
|
|
||||||
|
## The styling: the host asked for a thinner trace than the library's default
|
||||||
|
|
||||||
|
`RowSet::install()` (`src/plot_host.cpp:175`) builds every trace with `1.2f`. The library's
|
||||||
|
`Trace::width` default is `1.5f`, and `Style::crosshair_width`'s documentation treats 1.5 as *the
|
||||||
|
trace width a crosshair must read as thinner than*. So the app inverted its own stroke hierarchy
|
||||||
|
at the only place it states it, and `1.2f` is also, in both staging paths, the **minimum span a
|
||||||
|
short run is stroked at** — the `if (hi - lo < width)` widen — so it sets the floor on how
|
||||||
|
visible a one-row run can ever be.
|
||||||
|
|
||||||
|
`marker_px` (3.0) and `marker_below_samples` (50) are library defaults udpscope never restated;
|
||||||
|
`ui.cpp`'s one-shot style statement sets `intensity_grading` and nothing else.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In:**
|
||||||
|
|
||||||
|
- `deps/plotter`: the `lit_floor` correction in `stage_trace_density()`, a library test that fails
|
||||||
|
without it, and the `PLOT_TEST_FILTER` the measurement needed.
|
||||||
|
- `deps/plotter`: `Renderer::stage_trace_slopes()` — grading for the sparse polyline — plus
|
||||||
|
`FrameStats::slope_traces` and a library test that fails without it.
|
||||||
|
- `deps/plotter`: `Style::line_aa_px` and the grading constants remain *fields* — no new knob is
|
||||||
|
added, and `intensity_grading` stays **one** switch covering both rasterisation paths. The one
|
||||||
|
new field considered and rejected is named under **Out**.
|
||||||
|
- udpscope: the trace width at `src/plot_host.cpp:175`, and the style one-shot in `ui.cpp`
|
||||||
|
extended to state `marker_px`, `marker_below_samples`, `intensity_floor` and `line_aa_px`
|
||||||
|
rather than inheriting them.
|
||||||
|
- One new probe over the mock's `square`, and the `frame path:` reading extended to report the
|
||||||
|
dropped-row case so a regression is visible rather than merely ugly.
|
||||||
|
- `docs/claude/invariants-rendering.md`, `README.md`, and upstream's
|
||||||
|
`docs/data-and-performance.md` + `README.md`.
|
||||||
|
- `SPECS.md` is not changed.
|
||||||
|
|
||||||
|
**Out, and deliberately:**
|
||||||
|
|
||||||
|
- **A `Style` field for "always light a riser".** The dropped row is a bug in a test, not a
|
||||||
|
policy a host should get to choose; a flag would preserve the broken behaviour as an option and
|
||||||
|
oblige every host to know about it.
|
||||||
|
- **Changing the connector's tier.** Drawing the inferred bridge at the faintest step is a
|
||||||
|
deliberate honesty claim ("this is not a place the signal was measured to dwell"). Once
|
||||||
|
`lit_floor` is fixed the riser is *real* ink at its own tier and the connector is back to
|
||||||
|
bridging only genuine disjointness, which is what it was written for.
|
||||||
|
- **Per-trace or per-row width.** One width for every trace in the app, as now.
|
||||||
|
- **A separate switch for sparse grading.** Two switches would let a host reproduce the very
|
||||||
|
inconsistency this fixes, and the user's own framing settles it: the dimming is wanted *at all
|
||||||
|
zooms*, so it is one look and not two.
|
||||||
|
- **Normalising the sparse tiers against the frame.** The dense path normalises per column
|
||||||
|
because a frame-wide scale needs the whole image before anything can be drawn; the sparse
|
||||||
|
measure needs no normalisation at all, and leaving it absolute is what makes a pan stop
|
||||||
|
reshading the trace. Matching the dense path here would be matching a compromise.
|
||||||
|
- **A user-facing preference for any of these values.** `todo.md` item 9 owns the preferences
|
||||||
|
dialog; this work states defaults in code and leaves hooks for nothing.
|
||||||
|
- No change to the gather level, `MinMaxPyramid`, `dense_threshold_spp`,
|
||||||
|
`intensity_sub_columns`, the decimation plan, or the two-thread split.
|
||||||
|
|
||||||
|
## Global constraints
|
||||||
|
|
||||||
|
These bind every task below and are not restated in each one.
|
||||||
|
|
||||||
|
- **Every new source file carries the SPDX MIT header**, matching the existing files.
|
||||||
|
- **`stage_trace_density()` stays allocation-free on the frame path.** `column_rows_` is already
|
||||||
|
sized once per call and reused; the minimum-deposit tracking adds a scalar, not a container.
|
||||||
|
- **No figure is written into a document unless it was measured on this machine.** The arithmetic
|
||||||
|
above is derived and is labelled as derived; every number that reaches `README.md` or
|
||||||
|
`docs/claude/` comes from a probe run quoted from that run.
|
||||||
|
- **There is no unit test suite in udpscope.** Every assertion here is either a `deps/plotter`
|
||||||
|
test (that project has one) or a probe line on stderr driven through the real widgets, read
|
||||||
|
against the documented baselines: the documented build-warning count and zero `Gtk-CRITICAL`.
|
||||||
|
- **Run one probe at a time.**
|
||||||
|
- **The look is the user's call, the drop is not.** The `lit_floor` fix is correct or incorrect
|
||||||
|
and a test says which. The four style values are judgments; they are stated in one place, with
|
||||||
|
the before-value in a comment, so a second opinion is a one-line change.
|
||||||
|
|
||||||
|
## Task 0 — the test harness filter, already applied
|
||||||
|
|
||||||
|
`plottest::run_all()` ran all 376 cases unconditionally, so reading one case's output meant
|
||||||
|
running the suite. `PLOT_TEST_FILTER` is a substring match honoured in `run_all()`
|
||||||
|
(`deps/plotter/tests/harness.hpp`), unset meaning "all", which is what made the A/B above cheap
|
||||||
|
enough to iterate on. Documented in upstream's `docs/building-and-testing.md`, including the trap
|
||||||
|
that the summary line still counts the whole suite — so a filtered run says `377 cases, 0 failed`
|
||||||
|
having run one, and is never evidence that the suite passes.
|
||||||
|
|
||||||
|
## Task 1 — the library test that fails now
|
||||||
|
|
||||||
|
Upstream, beside `a_dense_trace_can_be_graded_by_dwell`:
|
||||||
|
`a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`.
|
||||||
|
|
||||||
|
The scratch measurement above is the shape of it, and was deliberately **not** committed: it
|
||||||
|
reports numbers rather than asserting, and it leans on the flat path as its reference, which the
|
||||||
|
column-11 note shows is not exact. The committed test keeps the construction and replaces the
|
||||||
|
reference.
|
||||||
|
|
||||||
|
The series and window are the measured ones — a true square of period 2098 over 60 000 x-units,
|
||||||
|
~105 samples a column — because that density puts the gather on raw samples and makes a flat
|
||||||
|
level's deposit a hundred times the riser's, which is the condition the defect needs. The riser
|
||||||
|
must **share** its column, pinned by construction rather than by luck: it is the phase that
|
||||||
|
survived in 10 of 116 columns, so a test that lets the phase float passes for the wrong reason
|
||||||
|
about one time in twelve.
|
||||||
|
|
||||||
|
The assertion is *continuity over the riser's own column*, not a brightness profile — the
|
||||||
|
existing grading test already profiles fifths and passes either way. Prefer the staged geometry
|
||||||
|
over a pixel readback so the failure names the dropped rows rather than reporting that the
|
||||||
|
picture changed, and have the failure print the gap's row range.
|
||||||
|
|
||||||
|
It must fail against the pre-fix `lit_floor`; verify that by reverting the one line, not by
|
||||||
|
trusting the figures in this document.
|
||||||
|
|
||||||
|
**Done.** `a_graded_column_keeps_a_riser_it_shares_with_a_flat_level`, beside the dwell test.
|
||||||
|
It chooses its riser columns by the property at stake — a band spanning more than half the axis,
|
||||||
|
which on a square wave is a transition and nothing else — from the ungraded frame, then re-reads
|
||||||
|
exactly those columns from the graded one and asserts 95 % of them are still tall. Reading
|
||||||
|
`kept=115` of `flat=116`, and `kept=10` with `lit_floor` reverted to `0.01f * peak`, which is the
|
||||||
|
required failure verified rather than assumed. The column-11 slack is in the assertion's 95 % and
|
||||||
|
its reason is in the comment.
|
||||||
|
|
||||||
|
## Task 1b — the library test for slope grading
|
||||||
|
|
||||||
|
`a_sparse_trace_is_graded_by_slope_like_a_dense_one`: one window either side of
|
||||||
|
`dense_threshold_spp` over the same transition at the same place, asserting that the two frames
|
||||||
|
took the two paths (`graded_traces` against `slope_traces`), that **neither dims the level**
|
||||||
|
(191 either way), that **both dim the riser** to under 55 % of it, and that the two riser
|
||||||
|
readings agree to within one tier. Reading 71/191 against 71/191; with the slope staging gated
|
||||||
|
off the sparse frame reads 191/191 and the path assertion fails first.
|
||||||
|
|
||||||
|
## Task 2 — review the fix
|
||||||
|
|
||||||
|
The change is in the tree. What is left is to read the comment at its site against the measured
|
||||||
|
numbers it quotes, and to confirm `Style::intensity_floor`'s doc comment now describes reality,
|
||||||
|
since it is cited above as having promised what the code did not deliver.
|
||||||
|
|
||||||
|
## Task 3 — the four stated values
|
||||||
|
|
||||||
|
`src/plot_host.cpp:175` from `1.2f` to at least the library's `1.5f`. The comment records that
|
||||||
|
the app had been asking for less than the default and that this width is also the minimum span a
|
||||||
|
short run is stroked at.
|
||||||
|
|
||||||
|
`ui.cpp`'s style one-shot (`src/ui.cpp:6149`) states, each with the inherited value in a comment:
|
||||||
|
|
||||||
|
- `marker_px` — `todo.md` 1.3, "the sample points should be bigger". The dots only appear below
|
||||||
|
`marker_below_samples` raw samples in the window, so this is the deep-zoom reading and is
|
||||||
|
independent of everything above.
|
||||||
|
- `marker_below_samples` — raising it brings the dots back at windows that currently show a bare
|
||||||
|
polyline.
|
||||||
|
- `intensity_floor` and `line_aa_px` — the two readings of `todo.md` 1.1, "the antialiasing
|
||||||
|
strategy with phosphore effect should be applied with more stregth", and they are different
|
||||||
|
things: `intensity_floor` lifts the *faintest graded tier*, which is what a riser is drawn at
|
||||||
|
even once it is lit; `line_aa_px` widens the anti-aliased fringe on **every** stroke in the
|
||||||
|
library, which is literally more anti-aliasing and makes a thin line read fatter at the cost of
|
||||||
|
a softer grid and axis. Both are stated; the values are the ones to look at on screen and
|
||||||
|
adjust, and the task's report says what each one did on its own.
|
||||||
|
|
||||||
|
Nothing here is conditional on Task 2, and nothing in Task 2 is conditional on these.
|
||||||
|
|
||||||
|
## Task 4 — the probe
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_EDGEINK_MS`: plot the mock's `square`, settle on a window dense enough for the
|
||||||
|
graded path, and report — per the line reference in `docs/claude/probe-readings.md` — the edge
|
||||||
|
column's staged span against the band it should cover, and the count of unlit rows inside it.
|
||||||
|
**It must read 0 dropped rows**, and it must be run with and without `UDPSCOPE_NO_PHOSPHOR=1`:
|
||||||
|
the ungraded path is the control, and the two must agree about where the trace is.
|
||||||
|
|
||||||
|
`frame path:` gains nothing new if the probe above covers the drop; the brief for this task
|
||||||
|
decides that from the probe's output, and whichever way it goes,
|
||||||
|
`docs/claude/probe-readings.md` gets the per-line entry.
|
||||||
|
|
||||||
|
## Task 5 — the documents
|
||||||
|
|
||||||
|
`docs/claude/invariants-rendering.md` already carries the grading invariant and the sentence
|
||||||
|
"the floor so that a fast riser, one sub-bucket deep everywhere, is never dimmed out of
|
||||||
|
existence". That is the claim this defect falsified, so the bullet gains the distinction the code
|
||||||
|
was missing — **an alpha floor is not an existence floor** — and names the symptom, the phase
|
||||||
|
dependence, and `UDPSCOPE_NO_PHOSPHOR=1` as the A/B that separates the two.
|
||||||
|
|
||||||
|
`README.md` records the stroke and marker values as deliberate deviations from the library's
|
||||||
|
defaults, under the existing deviations section.
|
||||||
|
|
||||||
|
Upstream `docs/data-and-performance.md` and `README.md` carry the `lit_floor` rule and the slope
|
||||||
|
path, since both are the library's contract and not this app's. **Done**, together with
|
||||||
|
`Style::intensity_grading`'s own doc comment (which described the dense path as the whole of the
|
||||||
|
feature), `docs/building-and-testing.md`'s `PLOT_TEST_FILTER` entry, and the case counts in all
|
||||||
|
three places that quote them — 350 → 377, and 333 → 360 for the coverage preset.
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# UDP packet capture: design
|
||||||
|
|
||||||
|
`todo.md` item 4: *optional udp frame storage for debugging of strange behaviours (store last
|
||||||
|
`<N>`, default = 1000, udp packets binary content on disk for forensic analysis).*
|
||||||
|
|
||||||
|
The strange behaviours worth forensics on are all *wire* behaviours — a counter that skips, a
|
||||||
|
datagram that arrives twice, a fragment that never completes, a CONFIG whose header does not parse,
|
||||||
|
a burst that outruns the reorder ring. Every one of them is invisible by the time a
|
||||||
|
`udps_frame_t` reaches `Acquisition::on_data`: the client has already validated the magic,
|
||||||
|
reassembled the fragments, resequenced what it could and dropped what it could not. A capture that
|
||||||
|
is to answer *what did the wire actually send* must therefore sit at `recv()`, and must store the
|
||||||
|
bytes without interpreting them.
|
||||||
|
|
||||||
|
This is a debugging aid, not a feature of the instrument. It is off by default, it adds nothing to
|
||||||
|
the glance tier of the status bar, and it must not be able to cost a single packet when it is on.
|
||||||
|
|
||||||
|
## The decisions taken, and what each one rules out
|
||||||
|
|
||||||
|
| Decision | Alternative rejected |
|
||||||
|
| --- | --- |
|
||||||
|
| Always-on rolling ring while enabled; the last N are on disk at all times | Keeping the ring in RAM and flushing on an anomaly or on demand — a crash or a `SIGKILL` then leaves nothing, which is the case a forensic capture exists for |
|
||||||
|
| A fixed-size file of N slots, plus a converter to pcap in `tools/` | Writing pcap directly (grows, so "last N" needs two rotating files and the newest N is split across both) or one file per datagram (1000 files, an open/write/close per packet in the receive path) |
|
||||||
|
| `mmap` once, `memcpy` per datagram | A `pwrite` per datagram (a syscall, and on a busy disk a blocking one, between two `recv()` calls) or a third thread with a queue (a second copy of every datagram, and this application's two-thread property is load-bearing) |
|
||||||
|
| One path per source, previous capture kept one deep | A timestamped file per enable (needs the retention policy `todo.md` item 9 defines and this does not build) or a single path overwritten (re-enabling destroys the capture you enabled for) |
|
||||||
|
| `--capture-packets[=N]` as well as the popover button | Button only — by the time the window is up and clickable, the first connect and its CONFIG are already history, and those are exactly the "strange behaviours" hardest to reproduce |
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three parts, each with one responsibility and a seam that does not leak the other two.
|
||||||
|
|
||||||
|
### 1. The client hands over raw datagrams
|
||||||
|
|
||||||
|
`deps/udpsclient` gains **one additive entry point**:
|
||||||
|
|
||||||
|
```c
|
||||||
|
/** Every datagram as recv() returned it, before the magic check, reassembly or
|
||||||
|
* resequencing: the bytes a capture has to store, and the only place malformed
|
||||||
|
* or duplicate datagrams are still visible. Runs on the polling thread, inside
|
||||||
|
* udps_client_poll(), like every other callback. `bytes` is valid until the
|
||||||
|
* callback returns. */
|
||||||
|
typedef void (*udps_datagram_cb)(const uint8_t *bytes, size_t len,
|
||||||
|
double recv_time, void *user);
|
||||||
|
|
||||||
|
void udps_client_set_datagram_cb(udps_client_t *client,
|
||||||
|
udps_datagram_cb cb, void *user);
|
||||||
|
```
|
||||||
|
|
||||||
|
A separate setter, **not** a fourth parameter on `udps_client_set_callbacks`
|
||||||
|
(`udps_client.h:283`): that signature has call sites, and a capture hook is no reason to change
|
||||||
|
them.
|
||||||
|
|
||||||
|
The call site is `udps_client.c:1458`, immediately after
|
||||||
|
|
||||||
|
```c
|
||||||
|
ssize_t n = recv(c->udp_fd, c->rxbuf, UDPS_RX_BUF_BYTES, 0);
|
||||||
|
```
|
||||||
|
|
||||||
|
returns a positive `n`, and before anything is read out of `rxbuf`. `recv_time` is the same clock
|
||||||
|
the client already stamps arrivals with, so a capture's times and the stream statistics agree.
|
||||||
|
|
||||||
|
**Boundary, stated rather than discovered later:** in multicast mode CONFIG arrives over TCP
|
||||||
|
(`udps_client.c:1142`) and this hook does not see it. The capture is of the UDP socket. A malformed
|
||||||
|
*multicast* CONFIG is therefore out of reach, and `README.md` will say so.
|
||||||
|
|
||||||
|
### 2. `PacketCapture` owns the file
|
||||||
|
|
||||||
|
New unit, `src/capture.hpp` / `src/capture.cpp`. One class, one responsibility — own the ring file
|
||||||
|
and store a datagram into it. It includes no GTK, no `ScopeModel`, and no client types beyond
|
||||||
|
`<cstdint>`; its whole interface is:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
class PacketCapture {
|
||||||
|
public:
|
||||||
|
/// Renames an existing capture to `capture-prev.bin`, creates and reserves the new file,
|
||||||
|
/// maps it. Returns false and leaves the object disabled on any failure, with `error()` set.
|
||||||
|
bool open(const std::string& path, std::uint32_t slot_count);
|
||||||
|
void close(); ///< unmap, close; idempotent
|
||||||
|
bool enabled() const noexcept;
|
||||||
|
|
||||||
|
/// One datagram. Called only from the producer thread. Never allocates, never blocks,
|
||||||
|
/// never fails: an over-long datagram is truncated to the slot and counted.
|
||||||
|
void store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept;
|
||||||
|
|
||||||
|
std::uint64_t stored() const noexcept; ///< datagrams since open()
|
||||||
|
std::uint64_t truncated() const noexcept;
|
||||||
|
std::uint32_t slot_count() const noexcept;
|
||||||
|
const std::string& path() const noexcept;
|
||||||
|
/// Copies the last open() failure out, or writes an empty string. Not a
|
||||||
|
/// `const std::string&`: see "What crosses between the threads" below.
|
||||||
|
void error(char* out, std::size_t cap) const noexcept;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### What crosses between the threads
|
||||||
|
|
||||||
|
The producer thread writes this state and the GTK thread reads it every frame for the popover, so
|
||||||
|
each field has to be publishable without a lock:
|
||||||
|
|
||||||
|
| Field | Type | Why it is safe |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `enabled_`, `stored_`, `truncated_` | `std::atomic<…>`, relaxed | counters and a flag, not a consistent set — the same treatment `StreamStats` already gets |
|
||||||
|
| `path_` | `std::string` | written **once** by the GTK thread before it ever sets `capture_want_`, and only read afterwards |
|
||||||
|
| the open failure | `char error_[128]` plus `std::atomic<bool> error_set_` | the producer fills the buffer and *then* sets the flag with a release store; the GTK thread copies the buffer only when the flag reads true |
|
||||||
|
|
||||||
|
The error is deliberately **not** a `std::string` accessor. A `std::string` written by the producer
|
||||||
|
inside `open()` and read by the GTK thread on the next frame is a data race with a reallocating
|
||||||
|
buffer — the kind that reads correctly for months. The fixed buffer with a flag published after it
|
||||||
|
is the same release-store trick the slot's `seq` field uses, for the same reason.
|
||||||
|
|
||||||
|
#### The file
|
||||||
|
|
||||||
|
```
|
||||||
|
offset 0, 64 bytes — header
|
||||||
|
magic u8[8] "UDPSCAP\0"
|
||||||
|
version u32 1
|
||||||
|
slot_count u32 N
|
||||||
|
slot_bytes u32 sizeof(record header) + UDPS_RX_BUF_BYTES, 8-byte aligned
|
||||||
|
cursor u32 next slot index to write (a hint; see below)
|
||||||
|
t0_realtime f64 CLOCK_REALTIME at open(), the epoch recv_time is relative to
|
||||||
|
peer_addr u32 the source's IPv4, network order
|
||||||
|
peer_port u16
|
||||||
|
local_port u16
|
||||||
|
reserved to 64 bytes, zeroed
|
||||||
|
|
||||||
|
offset 64 + i * slot_bytes, for i in [0, N) — slot i
|
||||||
|
t_arrival f64 the client's recv_time
|
||||||
|
len u32 bytes stored (after truncation, if any)
|
||||||
|
orig_len u32 bytes the wire delivered, so a truncation is visible
|
||||||
|
bytes u8[len]
|
||||||
|
seq u64 1-based ordinal since open(); written LAST
|
||||||
|
```
|
||||||
|
|
||||||
|
`seq` is written last, after the bytes, with a release store. Three things follow, and they are the
|
||||||
|
reason the format is this and not simpler:
|
||||||
|
|
||||||
|
- **A slot carrying a non-zero `seq` is complete.** No `fsync` is needed for the file to be
|
||||||
|
readable after a crash: the kernel's dirty pages are the file, and a half-copied slot still has
|
||||||
|
`seq == 0`.
|
||||||
|
- **`seq`, not `cursor`, is the order.** After the ring wraps, the newest slot is the one with the
|
||||||
|
largest `seq`, and the reader needs no trust in a header field that is updated out of band.
|
||||||
|
- **A torn write is detectable, not silent.** The reader reports the count of empty and torn slots
|
||||||
|
rather than averaging over them.
|
||||||
|
|
||||||
|
`cursor` is maintained anyway, because a human running `xxd` on the file wants to know where to
|
||||||
|
look.
|
||||||
|
|
||||||
|
#### Why the file is reserved, not sparse
|
||||||
|
|
||||||
|
`posix_fallocate` reserves the whole file at `open()`. The obvious alternative — `ftruncate` and
|
||||||
|
let the file stay sparse, so it costs only the bytes actually written — is wrong with `mmap`: a
|
||||||
|
store into a page the filesystem cannot back raises **`SIGBUS`**, which kills udpscope outright.
|
||||||
|
A capture must not be able to take down the instrument it is observing, and an out-of-space
|
||||||
|
condition must be an error at the moment the user clicks, not a crash ten minutes later.
|
||||||
|
|
||||||
|
At the default `N = 1000` and `UDPS_RX_BUF_BYTES = 65552`, the file is **~62.6 MiB**. That is
|
||||||
|
stated in the help text, in the tooltip and in the popover row, because it is the one cost of
|
||||||
|
switching the feature on.
|
||||||
|
|
||||||
|
### 3. `Acquisition` drives it, and the GTK thread only ever sets an atomic
|
||||||
|
|
||||||
|
`Acquisition` owns the `PacketCapture`, installs the datagram callback next to the three it already
|
||||||
|
installs (`src/acquisition.cpp:62`), and does **every** file operation on the producer thread.
|
||||||
|
|
||||||
|
```
|
||||||
|
GTK thread (the button) producer thread (the datagram callback)
|
||||||
|
--------------------------- --------------------------------------------
|
||||||
|
capture_want_.store(true) ──► reads capture_want_ at the top of each datagram
|
||||||
|
capture_path_ written once rising edge → open() : rename, create, fallocate, mmap
|
||||||
|
before the first store falling edge → close(): munmap, close
|
||||||
|
then, if enabled, store(bytes, len, recv_time)
|
||||||
|
```
|
||||||
|
|
||||||
|
No mutex, no queue, no second copy of any datagram, and the application still has exactly two
|
||||||
|
threads.
|
||||||
|
|
||||||
|
The one-off cost of a toggle is the `open()`: a rename, a create, a `posix_fallocate` of 62.6 MiB
|
||||||
|
and an `mmap`, on the producer thread. It is a user-initiated act that happens once per toggle, and
|
||||||
|
`README.md` will say that it can cost the packets in flight during it. Moving it off the producer
|
||||||
|
thread would need the lock or the thread this design exists to avoid, for an event that happens
|
||||||
|
when a human clicks a button.
|
||||||
|
|
||||||
|
The duration is **measured, not asserted**: `open()` records its own wall time and the probe prints
|
||||||
|
it as `open_ms`. `posix_fallocate` on a local extent-based filesystem is a metadata operation and
|
||||||
|
should be a handful of milliseconds, but that is a claim about the filesystem, not about this code,
|
||||||
|
and a reading above ~50 ms is a finding worth putting in `README.md` rather than a number to
|
||||||
|
predict here.
|
||||||
|
|
||||||
|
### Error handling, and only at this boundary
|
||||||
|
|
||||||
|
This is a syscall boundary, so it is validated; nothing else here is.
|
||||||
|
|
||||||
|
| Condition | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| `open`, `posix_fallocate` or `mmap` fails (ENOSPC, EACCES, a read-only cache directory) | capture stays off, `error()` carries `strerror`, one line on stderr, the toggle button returns to off and the popover row shows the reason |
|
||||||
|
| the cache directory does not exist | created with `g_mkdir_with_parents`, 0700; a failure there is the row above |
|
||||||
|
| `len > UDPS_RX_BUF_BYTES` | impossible — the slot is sized to the client's own receive buffer — but `store()` truncates to the slot, records `orig_len`, and counts it in `truncated()` rather than trusting the impossibility |
|
||||||
|
| the previous `capture-prev.bin` cannot be replaced | the capture still opens; the rename failure is reported, the old previous is kept |
|
||||||
|
| capture enabled with no connection yet | the button is insensitive until connected, because the path is keyed on the source; `--capture-packets` defers `open()` to the first datagram, which is what arms it before the first CONFIG |
|
||||||
|
|
||||||
|
## Surfaces
|
||||||
|
|
||||||
|
### The popover button and row
|
||||||
|
|
||||||
|
The status bar's detail popover (`src/ui.cpp:7619-7672`) is a two-column grid: four bold headings
|
||||||
|
at rows 0, 3, 5, 6 and seven value labels at rows 0..6, in `app->popover_label[11]`. It gains a
|
||||||
|
fifth heading, **Capture**, and an eighth value label; the array becomes `[13]` and the
|
||||||
|
`kHeadings` / `kHeadingRow` tables gain their entry. `update_status_popover`
|
||||||
|
(`src/ui.cpp:6101`) writes the new label like the others — `gtk_label_set_text` only, no rebuild.
|
||||||
|
|
||||||
|
The row reads one of:
|
||||||
|
|
||||||
|
```
|
||||||
|
off
|
||||||
|
on · 412/1000 slots · 26.1 MiB · ~/.cache/udpscope/192.168.1.40-5000/capture.bin
|
||||||
|
failed: No space left on device
|
||||||
|
```
|
||||||
|
|
||||||
|
Below the grid, a `GtkToggleButton` labelled **Capture packets** with the
|
||||||
|
`media-record-symbolic` icon and a tooltip naming the full path and the file's size. `toggled`
|
||||||
|
sets the atomic and nothing else. Insensitive until connected.
|
||||||
|
|
||||||
|
Nothing is added to `format_detail()`'s note string: `src/status_text.cpp:86` is a `char
|
||||||
|
note[160]` already close to its worst case, and the capture state is not something the exit dump's
|
||||||
|
one line needs.
|
||||||
|
|
||||||
|
### The command line
|
||||||
|
|
||||||
|
```
|
||||||
|
--capture-packets N store the last N raw datagrams on disk for forensics, in a
|
||||||
|
ring under the cache directory (1000 slots is ~62.6 MiB);
|
||||||
|
off unless given, and toggleable from the status bar
|
||||||
|
```
|
||||||
|
|
||||||
|
Parsed in `src/config.cpp`'s existing style — both `--capture-packets N` and
|
||||||
|
`--capture-packets=N`, `N` in 1..100000 — and the value is **required**, not optional.
|
||||||
|
`--capture-packets` with an optional value cannot be told apart from `--capture-packets` followed
|
||||||
|
by a value in this parser, and every other option in it takes the space-separated form, so a bare
|
||||||
|
flag would turn `--capture-packets 500` into `unknown argument: 500`. The 1000 default is the
|
||||||
|
slot count the **popover button** uses when the flag was not given; with the flag, the button uses
|
||||||
|
the N the flag named.
|
||||||
|
|
||||||
|
`Config::capture_packets` is a `std::uint32_t`: 0 means the flag was absent, and the button then
|
||||||
|
uses 1000.
|
||||||
|
|
||||||
|
The flag arms the ring before the first datagram; it is not persisted in the session file. That
|
||||||
|
persistence belongs with `todo.md` item 3's work, alongside the relative-time toggle deferred to
|
||||||
|
the same place.
|
||||||
|
|
||||||
|
### `tools/capture_to_pcap.py`
|
||||||
|
|
||||||
|
```
|
||||||
|
$ tools/capture_to_pcap.py ~/.cache/udpscope/192.168.1.40-5000/capture.bin out.pcap
|
||||||
|
1000 slots, 412 stored, 0 torn, 0 truncated → 412 packets, 26.1 MiB
|
||||||
|
$ wireshark out.pcap
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads the header, validates the magic and version, walks the slots, orders by `seq`, skips
|
||||||
|
`seq == 0`, and emits a classic pcap (`LINKTYPE_ETHERNET`, microsecond timestamps from
|
||||||
|
`t0_realtime + t_arrival`) with a synthesised Ethernet/IPv4/UDP header per record built from the
|
||||||
|
header's `peer_addr`, `peer_port` and `local_port`. Pure standard library — the mock already
|
||||||
|
forces a hard `numpy` dependency on `tools/`, and this script has no reason to add to that.
|
||||||
|
|
||||||
|
It also prints the counts above to stderr, which is what makes it a diagnostic in its own right:
|
||||||
|
`torn` or `truncated` above zero is a finding about the capture, before anything about the stream.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
udpscope has no unit test suite; the capture is verified by the application's own headless
|
||||||
|
self-check, and the converter by running it.
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_CAPTURE_MS` toggles capture **through the same handler the button uses**, waits a
|
||||||
|
scheduled interval, then reads the file back off disk and prints one line:
|
||||||
|
|
||||||
|
```
|
||||||
|
[capture-probe] enabled=1 path=… slots=1000 stored=412 cursor=412 torn=0 truncated=0
|
||||||
|
header_ok=1 first_seq=1 last_seq=412 payload=1648000 file=65576064
|
||||||
|
open_ms=3.1 t0=1760000000.123
|
||||||
|
```
|
||||||
|
|
||||||
|
`payload` is the sum of every stored slot's `len` — the bytes the wire actually delivered, which is
|
||||||
|
what tells you whether the ring is holding what you think. `file` is the reserved size,
|
||||||
|
`64 + slot_count * slot_bytes`, and is a constant for a given N: it is in the line so that a
|
||||||
|
mismatch against the file on disk is a reading rather than something to go and check.
|
||||||
|
|
||||||
|
What each field has to read, and the defect a wrong reading names, goes in
|
||||||
|
`docs/claude/probe-readings.md` beside the other probes. Two cases in
|
||||||
|
`docs/claude/verification.md`:
|
||||||
|
|
||||||
|
1. **enable at runtime** — the probe toggles on mid-run; `stored` must equal the datagrams the
|
||||||
|
exit dump counts from that instant, `torn=0`, and `first_seq=1`.
|
||||||
|
2. **wrap** — `--capture-packets=16` against the standard mock, run long enough to wrap several
|
||||||
|
times; `stored` exceeds `slot_count`, `last_seq - first_seq == 15`, `torn=0`. A wrap that loses
|
||||||
|
ordering, or a `cursor` that disagrees with `seq`, shows here and nowhere else.
|
||||||
|
|
||||||
|
Then the converter, run over the wrap case's file: exit 0, a record count equal to `slot_count`,
|
||||||
|
and the pcap's own first and last record timestamps inside the run's wall-clock interval. The
|
||||||
|
check is the converter's printed counts plus a `struct.unpack` of the pcap's first and last record
|
||||||
|
headers — no Wireshark, no `capinfos`, nothing outside the standard library, so it runs wherever
|
||||||
|
the self-check runs.
|
||||||
|
|
||||||
|
The standing baselines apply unchanged: a clean build at exactly four warnings, an incremental at
|
||||||
|
two, and zero `Gtk-CRITICAL` in any headless run.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- **Replaying a capture back into udpscope.** That is `todo.md` item 5's territory (opening stored
|
||||||
|
waveforms) and a different feature with a different shape.
|
||||||
|
- **Retention beyond one deep.** `capture.bin` and `capture-prev.bin`, nothing more, until item 9
|
||||||
|
defines the cache-size preferences.
|
||||||
|
- **Session persistence of the enable state.** Deferred to item 3 with the relative-time toggle,
|
||||||
|
so one piece of work adds the session fields rather than two.
|
||||||
|
- **Capturing the multicast TCP CONFIG channel.** Stated as a limitation in `README.md`.
|
||||||
|
- **Compression, encryption or rotation of the ring.** A fixed file of raw bytes is what a
|
||||||
|
forensic reader wants, and `gzip` exists.
|
||||||
|
|
||||||
|
## Documentation to update
|
||||||
|
|
||||||
|
- `README.md` — the feature, the 62.6 MiB cost, the producer stall on toggle, the multicast CONFIG
|
||||||
|
limitation, the converter's usage.
|
||||||
|
- `SPECS.md` — nothing: this is a `todo.md` item, not a spec requirement, and not a deviation.
|
||||||
|
- `todo.md` — item 4 struck, on one line, with the flag's name (GFM strikethrough is inline and
|
||||||
|
cannot span list items).
|
||||||
|
- `docs/claude/build-and-run.md` — the new option; `verification.md` and `probe-readings.md` — the
|
||||||
|
probe and its two cases; `docs/claude/invariants-data-path.md` — one paragraph on why the hook is
|
||||||
|
at `recv()` and why the file is reserved rather than sparse.
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# Noise rejection: design
|
||||||
|
|
||||||
|
Noise rejection in three places — the measurements pane, the trigger, and a new math node — aimed
|
||||||
|
at one problem: **electromagnetic peaks**. EM interference is *impulsive*. It arrives as a few
|
||||||
|
samples of large excursion, not as a raised noise floor, and that distinction decides every choice
|
||||||
|
below. An impulse recrosses a threshold several times per true crossing, so it inflates an edge
|
||||||
|
count; it moves a `min`, a `max` and a peak-to-peak outright; and it is the one kind of noise a
|
||||||
|
low-pass filter handles *badly*, smearing the spike across its settling time instead of removing
|
||||||
|
it while rounding the real edge beside it.
|
||||||
|
|
||||||
|
Each of the three places therefore gets two independent, optional controls:
|
||||||
|
|
||||||
|
| | amplitude qualifier | frequency qualifier |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Trigger** | hysteresis band about the level, in signal units | one-pole HF reject on the comparator's input, `fc` in Hz |
|
||||||
|
| **Measurements** | hysteresis band, % of the window's peak-to-peak | one-pole HF reject over the measured window, `fc` in Hz |
|
||||||
|
| **Math** | — | the `de-noise` node: median over N samples, then a one-pole at `fc` |
|
||||||
|
|
||||||
|
Nothing here changes a default. Every control is off, or set to the value the code already uses,
|
||||||
|
so the whole documented reading set must come out of this work unchanged. That is the first
|
||||||
|
verification assertion, not an afterthought.
|
||||||
|
|
||||||
|
## The decisions taken, and what each one rules out
|
||||||
|
|
||||||
|
| Decision | Alternative rejected |
|
||||||
|
| --- | --- |
|
||||||
|
| The trigger band is **absolute, in the channel's own units** | A percentage of a running peak-to-peak: the producer has no measured span, so it would need per-channel sliding-window state in the hot loop *and* a warm-up during which the band is wrong — which shows up as a missed or early latch, the one failure a trigger must not have |
|
||||||
|
| `band == 0` takes today's crossing test **verbatim**, with the latched-side comparator used only when the band is non-zero | One unified latched-side comparator: at `v == level` exactly the two are *not* equivalent (today it fires, a zero-width band decides nothing), and `feed_window`'s own comment records a ±1 square against a `[1, 1]` band firing 19 times on exact float equality. `probe-readings.md`'s `[condition-probe]` table of four exact latch times is a regression suite in disguise; unifying would require re-measuring all of it to prove a change nobody asked for |
|
||||||
|
| The band and the filter live **inside `EventDetector`** | Filtering in `Acquisition` before `engine.process()`: the buffer search deliberately runs the same evaluator (`condition.hpp:46-49`), so anything upstream of the detector has to be replicated there, and the live trigger and the search would drift apart |
|
||||||
|
| No shared hysteresis primitive extracted | A common `side_of()` header used by `measure.cpp` and `condition.hpp`: the genuinely shared part is about five lines, and the two call sites want different things from it — `measure.cpp` `continue`s on an in-band sample while counting enters, widths and folded buckets, the detector needs a side to feed four state machines. Three similar lines beat a premature abstraction; the *invariant* is stated once and pointed at |
|
||||||
|
| The measurement filter is a **real one-pole at a user `fc`**, and it disables the pyramid fast path | Measuring the pyramid's per-bucket means, which would be free and keep the fast path — but its corner is wherever the bucket size puts it (`fs/64`), so it is HF reject at one fixed, rate-dependent frequency rather than a cutoff the user can aim at the interference they actually have |
|
||||||
|
| The filter runs **inside each of the two raw walks** | Materializing a filtered copy of the window: `MeasureChunk`'s comment (`measure.hpp:108-115`) records that widening the window cost 60 % of the pane's time before a single statistic had been computed, and 17 MB at a 2.2 M-sample window |
|
||||||
|
| One combined `de-noise` node (median **then** pole) | A plain median node chained to the existing `LowPass`: two boxes for one idea, and it lets the user wire them in the wrong order — a pole *before* a median lets one spike contaminate the filter state for its whole settling time, which defeats the purpose |
|
||||||
|
| The median window is **N samples, fixed** | A duration converted to N from `dt`: N would then grow with decimation, so a modest duration at a long window becomes a large sort in the producer's hot loop and needs a hidden cap the user did not set |
|
||||||
|
| `MathOp::DeNoise` is **appended**, at index 12 | Inserting it beside the filters in `math_op_at()`, where it belongs in the dropdown: a node persists as its *index through that accessor* (`layout_store.cpp:358-365`), so inserting would silently reinterpret every node in every existing session file |
|
||||||
|
| The median's ring is **pooled in `MathRuntime`** | A `double[9]` member of `MathNodeRt`: 72 bytes added to *every* node, doubling a struct whose shape README's math-cost section records as a deliberate choice, to serve the one op whose state size is a parameter |
|
||||||
|
| A new **impulsive** mock source | Verifying against `--noise` (Gaussian): a median is specifically the wrong tool for a raised noise floor and the right one for impulses, so a Gaussian test would prove nothing about the feature |
|
||||||
|
|
||||||
|
## 1. The trigger
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
`EventCondition` (`condition.hpp:27`) gains two fields:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
double noise_band = 0.0; ///< hysteresis half-width about `level`, signal units. 0 = off.
|
||||||
|
double hf_fc = 0.0; ///< HF reject corner on the comparator's input, Hz. 0 = off.
|
||||||
|
```
|
||||||
|
|
||||||
|
Both **must** join `operator==` (`condition.hpp:37-41`). This is not bookkeeping:
|
||||||
|
`EventDetector::configure()` returns early when the condition compares equal
|
||||||
|
(`condition.cpp:9`) and the producer calls it on every packet, so a field omitted from the
|
||||||
|
comparison is a control that silently never takes effect.
|
||||||
|
|
||||||
|
The filter needs the sample interval to turn `fc` into a coefficient, and the detector is fed
|
||||||
|
`(value, t)` pairs rather than a rate. `configure()` therefore takes `dt` as a second argument and
|
||||||
|
keeps it as a `dt_` member, and the early return compares the new `dt` against `dt_` as well as the
|
||||||
|
condition against the stored one — a re-plan changes `dt`, and a coefficient left over from the
|
||||||
|
previous partition is a filter at the wrong corner.
|
||||||
|
|
||||||
|
### Comparator
|
||||||
|
|
||||||
|
`EventDetector` gains `int side_ = 0` (0 undecided, ±1 above/below) and one pole state word.
|
||||||
|
`crossed_up`/`crossed_down` (`condition.hpp:77-82`) stop being pure functions of `prev_` and
|
||||||
|
become flags computed **once per sample** in `feed()`:
|
||||||
|
|
||||||
|
- `noise_band == 0` → `prev_ < level && v >= level` and its mirror, bit for bit as today.
|
||||||
|
- `noise_band > 0` → `v >= level + band` latches `+1`, `v <= level - band` latches `-1`, anything
|
||||||
|
between decides nothing; a crossing *is* a change of latch.
|
||||||
|
|
||||||
|
Computing the pair once also removes a duplicated computation: `feed_edge`, `feed_pulse` and
|
||||||
|
`feed_dropout` each call both predicates today.
|
||||||
|
|
||||||
|
`feed_window` (`condition.cpp:115`) does not use those predicates, so it takes the band
|
||||||
|
explicitly on each edge of its own band: becoming *inside* requires `[lo+band, hi-band]`,
|
||||||
|
becoming *outside* requires leaving `[lo-band, hi+band]`. A band wide enough to close that inner
|
||||||
|
region is clamped to just under half the window's width — the same shape of clamp
|
||||||
|
`measure_set_level()` already applies for the same reason, and it keeps the panel's existing
|
||||||
|
refusal of a zero-width window meaningful.
|
||||||
|
|
||||||
|
`reset()` (`condition.cpp:14`) clears `side_` and the pole state.
|
||||||
|
|
||||||
|
### The filter
|
||||||
|
|
||||||
|
One pole on the comparator's input, `y += a·(x − y)`, with `a = 1 − exp(−2π·fc·dt)` built in
|
||||||
|
`configure()` and clamped below Nyquist as `biquad()` does (`math_channel.cpp:28`). `fc = 0`
|
||||||
|
gives `a = 1`, i.e. `y = x`, so "off" needs no branch in the inner loop.
|
||||||
|
|
||||||
|
Two consequences, both stated rather than discovered:
|
||||||
|
|
||||||
|
- **The latch moves later** by roughly `1/(2π·fc)` — the pole's group delay. This is inherent,
|
||||||
|
it is what a bench scope's HF reject also does, and it is why the control is off by default.
|
||||||
|
- **A NaN stays benign.** `feed()`'s comment (`condition.hpp:68-70`) records that a NaN sample
|
||||||
|
cannot fire because every comparison against it is false, and that *rejecting* one would leave
|
||||||
|
`prev_` stale and let the next sample fire a crossing that today cannot happen. The filter
|
||||||
|
preserves this: a non-finite input returns NaN (which flows into the existing comparisons, all
|
||||||
|
false) and leaves the pole state alone.
|
||||||
|
|
||||||
|
`last_value()` (`condition.hpp:73`) comes to report the comparator's input rather than the raw
|
||||||
|
sample. Its only consumer is the `v=%.4g` field of the trig-engine diagnostic
|
||||||
|
(`acquisition.cpp:918`), whose purpose is to say what the comparator saw.
|
||||||
|
|
||||||
|
### Panel
|
||||||
|
|
||||||
|
A `trigger_band_spin` and a `trigger_hf_spin` in `build_trigger_panel()` (`ui_trigger.cpp:219`),
|
||||||
|
read in `publish_trigger_fields()` (`:56`), shown for all four condition types in
|
||||||
|
`sync_trigger_rows()` (`:94`). The band takes the level spin's unit, digits and range, floored at
|
||||||
|
0.
|
||||||
|
|
||||||
|
### The search gets both for free
|
||||||
|
|
||||||
|
`EventDetector` holds no arming state precisely so the buffer search can run the same evaluator
|
||||||
|
over retained samples (`condition.hpp:46-49`). A band or a filter set in the panel therefore
|
||||||
|
changes what a search finds, consistently, with no second code path and no second thing to keep
|
||||||
|
in step.
|
||||||
|
|
||||||
|
### Not persisted
|
||||||
|
|
||||||
|
The trigger has never been persisted, so neither control is either; a restart comes up with both
|
||||||
|
off. That is also what makes every current `[condition-probe]` latch time hold unchanged.
|
||||||
|
|
||||||
|
## 2. The measurements
|
||||||
|
|
||||||
|
### Config
|
||||||
|
|
||||||
|
`MeasureOptions` (`measure.hpp:37`) gains two fields:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
double noise_pct = 10.0; ///< hysteresis half-width, % of the window's peak-to-peak. 0 = off.
|
||||||
|
double hf_fc = 0.0; ///< HF reject corner, Hz. 0 = off.
|
||||||
|
```
|
||||||
|
|
||||||
|
`noise_pct = 10.0` reproduces today's `kHysteresisFrac = 0.10` (`measure.cpp:36`) exactly, so
|
||||||
|
every current reading is identical. `measure_set_level()` (`measure.hpp:195`,
|
||||||
|
`measure.cpp:40`) already takes a `pct` — the *threshold* percent — so it gains a second,
|
||||||
|
separately named `noise_frac` argument for the band, replacing its read of the file-scope
|
||||||
|
`kHysteresisFrac`; the constant stays as `MeasureOptions::noise_pct`'s default so its
|
||||||
|
measured-value rationale — 5 % reads a 0.1 interferer correctly and fails at
|
||||||
|
0.2, 10 % holds to 0.3 and to white noise at σ=0.05 — does not become an orphan comment. The
|
||||||
|
clamp to half the room between the level and the nearer extreme survives untouched: it is what
|
||||||
|
keeps a threshold at 90 % measurable instead of reading `enters=0`.
|
||||||
|
|
||||||
|
### The band reaches the edge types only
|
||||||
|
|
||||||
|
`MeasureLevel::hysteresis`'s comment (`measure.hpp:165-169`) is explicit that the band moves the
|
||||||
|
*edge decision* only, and that the `sum`/`count` split stays at the bare level — because that
|
||||||
|
split **is** `Top`/`Bottom`, the mean of the samples above or below the threshold, and banding it
|
||||||
|
would change the documented exact `top = 2/π` on a unit sine.
|
||||||
|
|
||||||
|
So `noise_pct` is meaningful for `PulseDuration`, `Period` and `Frequency`, and does nothing for
|
||||||
|
`Top`/`Bottom`. The file's own rule (`measure.hpp:34-36`) is that an option which does nothing
|
||||||
|
for a type is left out of the dialog rather than stored and ignored, so this adds a
|
||||||
|
`measure_uses_noise()` beside `measure_uses_threshold()` and `measure_uses_side()`
|
||||||
|
(`measure.hpp:242-245`), and `measure_dialog_sync()` (`ui_measure.cpp:90`) shows the field for
|
||||||
|
those three types only.
|
||||||
|
|
||||||
|
A *raised* band also costs reads: `measure_accumulate_bucket()` already refuses a bucket where
|
||||||
|
the signal passes `level ± hysteresis` (`measure.hpp:211-216`), so a wider band opens more
|
||||||
|
buckets. The existing `measure cost:` line is where that shows up.
|
||||||
|
|
||||||
|
### HF reject reaches every type
|
||||||
|
|
||||||
|
The filter changes the samples rather than a decision, so `min`, `max`, `average`,
|
||||||
|
`peak to peak`, `amplitude`, `top`, `bottom` and every crossing type all read the filtered
|
||||||
|
signal. The control is therefore shown for all types.
|
||||||
|
|
||||||
|
It is seeded with the window's first sample, so the step transient at the left edge does not read
|
||||||
|
as a measurement. (Priming from samples *before* the window is possible — the ring holds the
|
||||||
|
history — and is deliberately not done: it would make a measurement depend on data outside the
|
||||||
|
window it reports on.)
|
||||||
|
|
||||||
|
### What it costs
|
||||||
|
|
||||||
|
`hf_fc > 0` disables **both** bucket fast paths, `measure_scan_buckets()` (`measure.hpp:147`) and
|
||||||
|
`measure_accumulate_bucket()` (`:217`): a bucket's `lo`/`hi`/`sum` describe raw samples and cannot
|
||||||
|
answer for a filtered signal. Both passes walk raw, which is the 1.2–1.6 ms → ~20 ms step at a
|
||||||
|
2.2 M-sample window that README's measurement-cost section was written about. Against the pane's
|
||||||
|
250 ms cadence that is a few percent of a core and a visible frame hitch.
|
||||||
|
|
||||||
|
This is accepted deliberately: the control is per-measurement and off by default, so nothing gets
|
||||||
|
slower unasked, and `measure cost:`'s `covered`/`buckets`/`raw` makes the cost a *reading* rather
|
||||||
|
than a mystery. `raw ≈ covered` is the signature of a filtered measurement.
|
||||||
|
|
||||||
|
The filter runs inside each of the two raw walks — three flops on a sample the walk is already
|
||||||
|
reading — rather than materializing a filtered window once. Filtering twice is deterministic and
|
||||||
|
free compared with the alternative measured in `MeasureChunk`'s comment.
|
||||||
|
|
||||||
|
NaN keeps the existing rule: the walks already skip a non-finite sample, and the filter state is
|
||||||
|
left alone, so a hole stays a hole and the filter resumes on real samples.
|
||||||
|
|
||||||
|
### Two documented deviations
|
||||||
|
|
||||||
|
1. `docs/claude/invariants-readouts-and-cursors.md` holds that a readout quotes a stored sample
|
||||||
|
and never a value between two. A filtered `max` is a value that was never stored, and with
|
||||||
|
`show_on_plot` its ruler sits at a level the drawn — raw — trace never reaches. This is
|
||||||
|
inherent to filtering, so it becomes a stated exception in that invariant rather than a silent
|
||||||
|
violation.
|
||||||
|
2. The documented exact baselines (`peak to peak = 2`, `top = 0.636620`) hold with the filter
|
||||||
|
off. With it on they move, correctly; the probe reads both.
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
Free. A measurement is written as keyed TOML (`layout_store.cpp:659-672`) and read key by key
|
||||||
|
(`:544-556`), so two new keys load in an old session at their defaults.
|
||||||
|
|
||||||
|
## 3. The `de-noise` math node
|
||||||
|
|
||||||
|
### The op, appended
|
||||||
|
|
||||||
|
`MathOp::DeNoise` goes last in the enum (`math_channel.hpp:15-28`) and at index 12 in
|
||||||
|
`math_op_at()` (`math_channel.cpp:99-114`), with `Div` given an explicit `case 11` so the current
|
||||||
|
`default` no longer swallows it; `math_op_count()` becomes 13 and `math_op_name()` gains
|
||||||
|
`"de-noise"`.
|
||||||
|
|
||||||
|
Indices 0–11 keeping their meaning is exactly what leaves every existing session file reading
|
||||||
|
correctly, because a node persists as its index through that accessor. The price is that
|
||||||
|
de-noise sits last in the dialog's dropdown rather than beside the filters, which is the right
|
||||||
|
side of that trade.
|
||||||
|
|
||||||
|
### Parameters
|
||||||
|
|
||||||
|
`MathNode` (`math_channel.hpp:48-72`) gains `int med_n = 5;` — odd, 3 to 9, rounded to odd and
|
||||||
|
clamped. `fc` is reused for the HF reject; `q` is unused. `fc = 0` means median only, and it
|
||||||
|
expresses "off" without a branch: the pole's coefficient `a = 1 − exp(−2π·fc·dt)` is exactly 1 at
|
||||||
|
`fc = 0`.
|
||||||
|
|
||||||
|
The coefficient is built in `math_prepare()` (`math_channel.cpp:140-165`) where `dt` is known and
|
||||||
|
rebuilt on a `dt` change, exactly as the biquads are, with the same sub-Nyquist clamp
|
||||||
|
(`:28`).
|
||||||
|
|
||||||
|
### Predicates
|
||||||
|
|
||||||
|
`math_op_uses_filter()` (`math_channel.cpp:135`) keeps meaning **biquad** — it gates coefficient
|
||||||
|
construction at `:162`, and a de-noise node must not be given biquad coefficients. The dialog
|
||||||
|
gets a new `math_op_uses_fc()` (filter ‖ de-noise) and a `math_op_uses_median()` for the new spin
|
||||||
|
row. `math_dialog_sync_form()` (`ui_math.cpp:889-981`) already shows and hides one widget set per
|
||||||
|
op, and `q`'s row being visible for `BandPass` alone is the precedent for a per-op subset of the
|
||||||
|
filter row.
|
||||||
|
|
||||||
|
### Node text
|
||||||
|
|
||||||
|
`math_node_text()` (`layout_store.cpp:358`) emits a 13th pipe field. `math_node_from_text()`
|
||||||
|
accepts **12 or 13** fields, defaulting `med_n` when it reads 12, so existing sessions load
|
||||||
|
unchanged. A file written by this build and read by an older one is rejected by that build's
|
||||||
|
`f.size() != 12u` check (`:385`); there is no forward-compatibility requirement here, but it is
|
||||||
|
recorded rather than left as a surprise.
|
||||||
|
|
||||||
|
### Runtime state, pooled
|
||||||
|
|
||||||
|
`MathRuntime` (`math_channel.hpp:115-120`) gains one `std::vector<double>` holding the median
|
||||||
|
rings of whichever nodes need them; `MathNodeRt` gains an offset into it, a ring position and the
|
||||||
|
effective N. Not a fixed array member, for the reason in the decisions table. One indirection on
|
||||||
|
a path already doing N compares is free, and the choice is reversible if measurement disagrees.
|
||||||
|
|
||||||
|
### Evaluation
|
||||||
|
|
||||||
|
In `math_step()` (`math_channel.cpp:167-230`), the new case is: push the input into the ring,
|
||||||
|
take the median, then one pole step. **Median first** — de-spiking before smoothing is the whole
|
||||||
|
point, and the reverse lets one spike contaminate the filter state for its entire settling time.
|
||||||
|
|
||||||
|
The median is an insertion sort of at most nine doubles in a local array: ~20 compares worst
|
||||||
|
case, ~4 at N=3, against a documented 80–100 ns per slot for a whole three-graph runtime. That
|
||||||
|
ratio is measured and reported through the existing math-cost line, not assumed.
|
||||||
|
|
||||||
|
**Warm-up:** before the ring holds `med_n` samples the node outputs the median of what it holds,
|
||||||
|
which is a correct median of a shorter window. Deliberately not NaN — in this application a NaN
|
||||||
|
output *means a hole in the data* (`math_channel.cpp:64-70`), and spending that meaning on a
|
||||||
|
start-up transient would make a gap ambiguous.
|
||||||
|
|
||||||
|
**NaN** matches `biquad_step` exactly: a non-finite input returns NaN and leaves both the median
|
||||||
|
ring and the pole state alone, so the hole stays visible and the node resumes on real samples.
|
||||||
|
|
||||||
|
## 4. Verification
|
||||||
|
|
||||||
|
There is no unit test suite; the check is the application's own headless self-check under
|
||||||
|
`xvfb-run` against `tools/mock_udpstreamer.py`, read against two baselines (an exact warning
|
||||||
|
count, and no `Gtk-CRITICAL`).
|
||||||
|
|
||||||
|
### The regression assertion, first
|
||||||
|
|
||||||
|
Every default is today's behaviour, so the documented reading set must be **unchanged**:
|
||||||
|
|
||||||
|
- `[condition-probe]`'s four exact latch times (`t0=1.710000`, `2.160100`, `1.706700`,
|
||||||
|
`4.120200`);
|
||||||
|
- `peak to peak=2`, `amplitude=1`, `average=0`, `top=0.636620`;
|
||||||
|
- `measure cost:` with `raw ≪ covered`;
|
||||||
|
- the build's warning count — adding a field to `MathNode` leaves
|
||||||
|
`ui_probe.cpp:304` and `:313` at one `-Wmissing-field-initializers` each, so four on a clean
|
||||||
|
build and two on an incremental one still holds.
|
||||||
|
|
||||||
|
### The mock needs an impulsive source
|
||||||
|
|
||||||
|
`tools/mock_udpstreamer.py` offers `--noise` (Gaussian), `--glitch-at`/`--glitch-width` (a single
|
||||||
|
glitch at one time) and `--clock-jitter`. None of them is a *stream* of EM-like peaks, and a
|
||||||
|
median is the wrong tool for Gaussian noise, so none of the three pieces could be shown to work
|
||||||
|
against the mock as it stands.
|
||||||
|
|
||||||
|
Add `--spike-every S --spike-amp A --spike-width N`: a recurring impulse of N samples at
|
||||||
|
amplitude A, every S seconds. One flag set, and it is what every reading below is measured on.
|
||||||
|
|
||||||
|
### The four readings
|
||||||
|
|
||||||
|
1. **Trigger band** — a new `[condition-probe]` leg on a slow sine plus spikes. With
|
||||||
|
`noise_band = 0` the latch lands on a spike; with the band above the spike amplitude it lands
|
||||||
|
on the sine's own crossing time. The latched `t0` is the assertion, and the four existing
|
||||||
|
shapes are re-read unchanged in the same run.
|
||||||
|
2. **Trigger HF reject** — the same leg with the band off and `hf_fc` set. The latch moves
|
||||||
|
*later* by roughly `1/(2π·fc)`, reported beside the prediction. A latch that does not move is
|
||||||
|
the filter not being in the comparator's path.
|
||||||
|
3. **Measurements** — `Frequency` on the spiky mock read three ways: `noise_pct = 0` (counts
|
||||||
|
spikes, frequency far too high), `= 10` (the true frequency), and `hf_fc` set (the true
|
||||||
|
frequency *and* `raw ≈ covered`, which is the fast-path loss as a reading). Plus `peak to
|
||||||
|
peak` with the filter off and on, since that is the number the filter is supposed to move.
|
||||||
|
4. **The de-noise node** — `math_probe` (`ui_probe.cpp:304`) gains a de-noise graph on the spiky
|
||||||
|
channel. Its output's peak-to-peak must fall to the clean waveform's while a `LowPass` at the
|
||||||
|
same `fc` does not: that contrast is the entire justification for the node existing. Plus the
|
||||||
|
per-slot cost through the existing math-cost line.
|
||||||
|
|
||||||
|
## 5. Documentation
|
||||||
|
|
||||||
|
| File | What it gains |
|
||||||
|
| --- | --- |
|
||||||
|
| `README.md` | A noise-rejection section covering the three places, and the filtered-readout deviation stated rather than quietly taken |
|
||||||
|
| `docs/claude/invariants-readouts-and-cursors.md` | The stated exception: a filtered readout is not a stored sample, and its on-plot ruler can sit where the raw trace never goes |
|
||||||
|
| `docs/claude/invariants-measurements.md` | A filter on a measurement disables both bucket fast paths, and `raw ≈ covered` is its signature |
|
||||||
|
| `docs/claude/invariants-trigger-and-pinning.md` | The band, the `band == 0` verbatim path and why, and the latch delay a filter adds |
|
||||||
|
| `docs/claude/invariants-math-channels.md` | Op indices are append-only because the session format stores them; the pooled median state |
|
||||||
|
| `docs/claude/verification.md`, `docs/claude/probe-readings.md` | The four readings above and what a wrong value names |
|
||||||
|
| `docs/claude/build-and-run.md` | The new mock flags |
|
||||||
|
|
||||||
|
## 6. Out of scope
|
||||||
|
|
||||||
|
- Persisting the trigger. The trigger has never been persisted; the band and the filter follow
|
||||||
|
that, rather than this change being the one that introduces trigger persistence.
|
||||||
|
- Any averaging acquisition mode. Noise rejection here is per-measurement, per-trigger and
|
||||||
|
per-node; nothing changes what the ingest stores, so a raw trace stays raw and every reading
|
||||||
|
remains traceable to stored samples.
|
||||||
|
- Noise rejection on the cursors or the search's own UI. The search inherits the trigger's
|
||||||
|
condition and so inherits both controls; nothing further is added.
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# A tabbed side panel, and one channel list: design
|
||||||
|
|
||||||
|
The side panel stacks five sections in one scrolling column — trigger, cursors, measurements,
|
||||||
|
math, signals — each behind a `GtkRevealer` opened by its own toolbar toggle. With trigger,
|
||||||
|
measurements, cursors and math all open the column is unusable: every section is short of room,
|
||||||
|
and finding one means scrolling past three others.
|
||||||
|
|
||||||
|
Two changes, aimed at that one problem:
|
||||||
|
|
||||||
|
1. **The column becomes two tabs.** *Measure* holds the cursors readout and the measurements;
|
||||||
|
*Signals* holds the trigger and the channel list. One tab is on screen at a time, so no
|
||||||
|
section competes with four others for height.
|
||||||
|
2. **The math list is folded into the signal list.** A math channel already *is* a channel in
|
||||||
|
the model, and the two lists were the same list drawn twice. One list, with a `MATH`
|
||||||
|
divider in it.
|
||||||
|
|
||||||
|
A third change falls out of the first. A tab is one-of-N, so it can no longer carry an on/off:
|
||||||
|
**no toggle hides a pane any more.** Trigger-off and cursors-off put their pane in a disabled
|
||||||
|
state and leave it where it is.
|
||||||
|
|
||||||
|
## The decisions taken, and what each one rules out
|
||||||
|
|
||||||
|
| Decision | Alternative rejected |
|
||||||
|
| --- | --- |
|
||||||
|
| `AdwViewStack` + `AdwViewSwitcher` | `GtkStack` + `GtkStackSwitcher`: plain buttons beside an `AdwOverlaySplitView`, which is what the sidebar already is. `GtkNotebook`: draws its own tab strip, which costs width in a 320 px sidebar and reads as off-platform. libadwaita ≥ 1.8 is already a hard requirement (`CMakeLists.txt:22`), so the platform widget costs nothing new |
|
||||||
|
| **A scroller per page**, inside the stack | One shared scroller around the stack: the two pages are different heights, so a tab switch would leave the scroll position somewhere the new page did not ask for |
|
||||||
|
| The switcher sits **above** the scrolled area | Inside it: the one control that chooses what you are looking at must not be the first thing to scroll away |
|
||||||
|
| **Two** tabs, exactly as grouped | A tab per section (five tabs): that is the stack again, laid sideways, and a 320 px sidebar has no room for five switcher pills. Measurements read what the cursors bracket, and the trigger picks a channel from the list beside it — the grouping follows the way the panes are used |
|
||||||
|
| Nothing inside a tab collapses | Keeping a revealer per section inside the tab, or a `GtkPaned` between them: the first reintroduces exactly the state-management the tabs remove, two sections at a time instead of five; the second adds a divider position to persist per tab for a column that already scrolls |
|
||||||
|
| Trigger-off and cursors-off **disable** their pane | Hiding it, which is today's behaviour: a pane you can see is a pane you can read, and the settings you are about to turn back on should not vanish while you look for them |
|
||||||
|
| The `measurements` and `math` toolbar toggles are **deleted** | Keeping them as tab selectors: two buttons that select the same tab, whose pressed state duplicates the switcher's |
|
||||||
|
| The switcher shows **titles only**, and the two icons the deleted toggles used are **removed** | Giving each page an `icon-name`: the measurements icon would carry over, but *Signals* has no glyph — reusing the trigger icon for a page that is mostly a channel list misnames it, and drawing a new one is new artwork under the icon authoring rules for a label two words long |
|
||||||
|
| One row widget for both kinds of channel, differing **only in its menu** | Inline edit and delete buttons on math rows (today's math pane): math rows then carry visibly more controls than signal rows, and the list stops reading as one column |
|
||||||
|
| A `+` on the `MATH` divider **only** | A `+` on the signals header too: a signal comes from CONFIG and cannot be added, so the button would have nothing to do |
|
||||||
|
| The statistics accumulator keeps running on the **hidden** page | Gating `update_measures()` on the visible tab: the accumulator is history, so min/max/avg would gain a gap for exactly as long as you looked at the trigger |
|
||||||
|
| The probe reads the **page name and sensitivity** | Dropping the two revealer readings: the defect they catch — a panel that is built but not on screen — still exists, it is just spelled differently |
|
||||||
|
|
||||||
|
## 1. The container
|
||||||
|
|
||||||
|
The sidebar child of `side_view` becomes a vertical `GtkBox`:
|
||||||
|
|
||||||
|
```
|
||||||
|
AdwOverlaySplitView.side_view
|
||||||
|
sidebar: GtkBox (vertical)
|
||||||
|
AdwViewSwitcher <- never scrolls
|
||||||
|
AdwViewStack
|
||||||
|
page "measure" GtkScrolledWindow -> GtkBox: cursors, separator, measurements
|
||||||
|
page "signals" GtkScrolledWindow -> GtkBox: trigger, separator, signal list
|
||||||
|
```
|
||||||
|
|
||||||
|
`side_scroll` moves inside the pages; `App::side_box` becomes the stack, and the five revealer
|
||||||
|
fields in `ui_app.hpp` are removed with the code that sets them (`ui.cpp:4728-4767`).
|
||||||
|
|
||||||
|
The switcher carries the two page titles and no icons. `udpscope-measure-symbolic` and
|
||||||
|
`udpscope-math-symbolic` lose their last user with the toggles that used them, so both leave
|
||||||
|
`src/icons/scalable/actions/` and `udpscope.gresource.xml`: the bundled set goes from six
|
||||||
|
action icons to four, which `invariants-gtk-and-chrome.md` counts and must be corrected with
|
||||||
|
the change.
|
||||||
|
|
||||||
|
The pages are built by the functions that build them today — `build_trigger_panel()`,
|
||||||
|
`build_cursors_panel()`, `build_measures_panel()`, `build_signal_panel()` — unchanged except
|
||||||
|
that they are appended to a page's box instead of to a revealer.
|
||||||
|
|
||||||
|
## 2. State instead of visibility
|
||||||
|
|
||||||
|
| Control | Today | After |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `trigger_toggle` | trigger mode on/off **and** reveals the trigger pane (`ui_trigger.cpp:198`) | trigger mode on/off; the pane's body goes sensitive/insensitive |
|
||||||
|
| `cursors_toggle` | cursors on/off **and** reveals the readout (`ui_cursors.cpp:124-126`) | cursors on/off; the readout goes insensitive and its values blank to `—` |
|
||||||
|
| `cursors_reset` | hidden while cursors are off (`ui_cursors.cpp:122`) | insensitive while cursors are off |
|
||||||
|
| `measures_toggle` | reveals the measurements (`ui.cpp:4541-4550`) | **deleted** |
|
||||||
|
| `math_toggle` | reveals the math pane (`ui.cpp:4551-4558`) | **deleted** |
|
||||||
|
| `sidebar_toggle`, `events_toggle`, `pause_button` | — | unchanged |
|
||||||
|
|
||||||
|
The two deleted toggles leave `src/ui/header-bar.ui` with their handlers, so the bar is
|
||||||
|
declared with five controls and no id resolves to a button nothing listens to.
|
||||||
|
|
||||||
|
`update_side_visibility()` (`ui.cpp:2830-2850`) keeps its job — the whole sidebar is still the
|
||||||
|
split view's to show, still gated on a CONFIG having named a signal — minus the line that showed
|
||||||
|
`signal_toggle`, which no longer exists.
|
||||||
|
|
||||||
|
Everything else those handlers do is untouched: `tc.armed`, the epoch rule and the
|
||||||
|
`set_time_window()` call in `on_trigger_toggled()`, `sync_events_availability()`, the
|
||||||
|
Arm/Disarm label, and `rows->set_cursors_visible()` with its `reset_cursors()`.
|
||||||
|
|
||||||
|
Blanking the cursor readout to `—` rather than leaving the last values is the readout invariant
|
||||||
|
restated: a readout quotes a stored sample, and with the instruments off there is no sample it
|
||||||
|
quotes.
|
||||||
|
|
||||||
|
## 3. One channel list
|
||||||
|
|
||||||
|
**The duplication is literal, and the merge is mostly a deletion.**
|
||||||
|
`rebuild_signal_list()` is handed `model.signals()`, and `append_math_signals_locked()`
|
||||||
|
(`model.cpp:252-269`) has already appended one synthetic entry per math channel — *"they go
|
||||||
|
through the ordinary signal path on purpose: a derived channel is a channel"*, says its own
|
||||||
|
comment. Nothing in the list filters `sig.math`, so **every math channel is already a row in
|
||||||
|
the signal list**, with its tick, chip, name, unit and menu. The math pane listed the same
|
||||||
|
channels a second time.
|
||||||
|
|
||||||
|
What the list gains:
|
||||||
|
|
||||||
|
- a non-selectable `MATH` divider row before the first math row, carrying the `+` that opens
|
||||||
|
the node editor on a new channel;
|
||||||
|
- two items in a math row's menu.
|
||||||
|
|
||||||
|
| | signal row | math row |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| tick | acquire (hidden in a file view) | enabled |
|
||||||
|
| colour chip | clickable while plotted | unchanged |
|
||||||
|
| unit | from CONFIG | the channel's own |
|
||||||
|
| menu | Information, Default colour, Calibrate… | **Edit graph…**, Default colour, **Delete** |
|
||||||
|
|
||||||
|
Information and Calibrate… are a *streamed* signal's — calibration is applied at ingest — so a
|
||||||
|
math row carries neither. No rename item either: the node editor owns a math channel's name and
|
||||||
|
unit (`src/ui/math-editor.ui:37-43`), so *Edit graph…* is where both are changed, and a second
|
||||||
|
place to change them would be a second answer to the same question.
|
||||||
|
|
||||||
|
Two things already work and must not be rebuilt: the tick is already the right switch —
|
||||||
|
`enable_locked()` (`model.cpp:626-639`) mirrors it into the `MathChannel`'s own `enabled` flag —
|
||||||
|
and the row's `GtkDragSource` already lets a math channel be dropped onto a plot row, which is
|
||||||
|
how `math1` reached row 0 in the session this work started from.
|
||||||
|
|
||||||
|
One defect to fix while merging: `App::math` is the copy the session file is written from, so a
|
||||||
|
tick on a math row must mirror `enabled` into it before `schedule_layout_save()`. Today the pane's
|
||||||
|
checkbox did that (`ui.cpp:2276`) and the list's tick did not, so the two controls disagreed
|
||||||
|
about what gets saved.
|
||||||
|
|
||||||
|
Deleted: `build_math_panel()` (`ui_math.cpp:1044`), `rebuild_math_pane()` (`ui.cpp:2251-2319`),
|
||||||
|
`src/ui/math-panel.ui` and its entry in `src/ui/udpscope-ui.gresource.xml`, `App::math_revealer`,
|
||||||
|
`App::math_list`, `App::math_toggle`, `App::measures_toggle`, `App::signal_revealer`, and
|
||||||
|
`App::signal_toggle` with `on_signals_toggled()` (`ui_signals.cpp:303-307`) and its overflow-menu
|
||||||
|
row (`ui.cpp:4452-4455`) — the list is a page now, so "show or hide the signal list" has nothing
|
||||||
|
to hide. The node editor dialog itself is untouched; only the button that opens it moves.
|
||||||
|
|
||||||
|
## 4. Persistence
|
||||||
|
|
||||||
|
One key, top level in the session file beside `window_s` and `measure_stats`
|
||||||
|
(`layout_store.cpp`):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
side_tab = "signals" # or "measure"
|
||||||
|
```
|
||||||
|
|
||||||
|
Written on save; applied once where the rest of the layout is applied, so an unknown or missing
|
||||||
|
value leaves the stack on its first page. Nothing else about the panel is persisted today and
|
||||||
|
nothing else needs to be: no revealer states remain, and the 320 px width is still the `.ui`'s.
|
||||||
|
|
||||||
|
## 5. What runs on a hidden page
|
||||||
|
|
||||||
|
`update_cursors_pane()` and `update_measures(force=false, accumulate=true)` are called every
|
||||||
|
frame from the tick (`ui.cpp:3275-3276`) and keep being called whichever page is current.
|
||||||
|
Writing a label on an unrealized widget is legal; **reading its allocation is not**. Every
|
||||||
|
allocation read in the sidebar is a debug capture — `dbg_measures_pane_w/h`
|
||||||
|
(`ui.cpp:3237-3239`), `dbg_signal_w/h` and `dbg_trigger_w/h` (`ui_probe.cpp:77-80`) — and each
|
||||||
|
reads 0 on the page that is not current. That is a change in what the reading means, which §6
|
||||||
|
handles; it is not a defect, and no functional code may start depending on those numbers.
|
||||||
|
|
||||||
|
This is the revealer trap in a new widget. `invariants-gtk-and-chrome.md:174-181` records that
|
||||||
|
an unrevealed `GtkRevealer` measures 0×0 and is still given the leftover width; a stack's
|
||||||
|
hidden page measures 0×0 too, and the rule it teaches — ask about *state*, not about size —
|
||||||
|
carries over unchanged.
|
||||||
|
|
||||||
|
## 6. Verification
|
||||||
|
|
||||||
|
Readings that change, and what each must say instead:
|
||||||
|
|
||||||
|
| Reading | Was | Becomes |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `dbg_signal_revealed` (`ui_probe.cpp:90`) | the signal revealer's child is revealed | the `signals` page is current **and** its list is non-empty |
|
||||||
|
| `dbg_trigger_revealed` (`ui_probe.cpp:92`, `:2190`) | the trigger revealer's child is revealed | the `signals` page is current **and** the trigger body is sensitive |
|
||||||
|
| `dbg_measures_pane_w/h` | the pane's size while revealed | the pane's size while its page is current; 0 otherwise, and read as such |
|
||||||
|
| the panel-toggle reading (`ui_probe.cpp:104`) | both menu toggles are visible | the switcher is visible, which is the one control those two became |
|
||||||
|
|
||||||
|
One new probe, `UDPSCOPE_PROBE_SIDE_TAB_MS`: select the page that is not current, then dump the
|
||||||
|
page name, the measurement row count and the accumulator's sample count. The count must have
|
||||||
|
advanced across the switch — that is the §5 rule under test, and the defect it names is a
|
||||||
|
statistics pane that silently restarts its history every time you look at the trigger.
|
||||||
|
|
||||||
|
New reading for the merged list, on the existing signal-panel probe: the row count must be
|
||||||
|
signals + 1 + math channels, and the math rows must carry the same widget shape as a signal
|
||||||
|
row. A count short by the divider is a list that lost its `+`.
|
||||||
|
|
||||||
|
Baselines, unchanged and re-read on every run: exactly four warnings on a clean build, no
|
||||||
|
`Gtk-CRITICAL`, `test-ui` 15/15.
|
||||||
|
|
||||||
|
Docs to update with the work: `probe-readings.md` (the three changed lines and the two new
|
||||||
|
ones), `verification.md` (a bullet for the tab switch), `invariants-gtk-and-chrome.md` (the
|
||||||
|
sidebar is a view stack, the hidden-page trap replaces the unrevealed-revealer one for it, the
|
||||||
|
bar carries five controls and the bundled set four action icons),
|
||||||
|
`invariants-math-channels.md` (the list lives in the signal panel), `invariants-readouts-and-cursors.md`
|
||||||
|
(the readout blanks when the instruments are off), and a README note recording the deviation
|
||||||
|
from `SPECS.md` where it names the panes.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
The events pane left of the plot, export, the node editor's internals, and the status bar.
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# MCC E-1608 source plugin (sub-project 3 of 4)
|
||||||
|
|
||||||
|
Date: 2026-10-31
|
||||||
|
Branch: `feature/source-e1608` (from `feature/source-plugins`, sub-project 1)
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Sub-project 1 added the `DataSource` / `SourceKind` / `SourceRegistry` framework and ported
|
||||||
|
UDPS onto it. This sub-project adds a second kind, `e1608`: the Measurement Computing E-1608
|
||||||
|
Ethernet DAQ (8 SE / 4 DIFF 16-bit analog inputs, 2 analog outputs, 8 DIO bits, one 32-bit
|
||||||
|
counter), with its own connect form and two side-stack panels.
|
||||||
|
|
||||||
|
References for the wire protocol, both written by the user:
|
||||||
|
|
||||||
|
- Primary: `~/Projects/marte2_projects/MCC_E1608_DataSource`, a MARTe2 DataSource
|
||||||
|
(`E1608DataSource.{h,cpp}`, `E1608ScanFifo`). Its class comment in the header is the
|
||||||
|
authoritative list of parameters and behaviour. It is bound to MARTe2 (`BasicTCPSocket`,
|
||||||
|
`StreamString`, `SingleThreadService`), so it is **ported** to plain C++, not linked.
|
||||||
|
- Cross-check: `~/Projects/uopi/internal/datasource/mcc/{protocol,e1608}.go`, an independent
|
||||||
|
Go implementation of the same protocol.
|
||||||
|
|
||||||
|
No board is available. The mock is the only verification until one is attached.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
| --- | --- |
|
||||||
|
| DAC / generator | Static level plus a software waveform (sine, square, triangle, ramp), 10 Hz at most. Runs as a timer inside `poll()`; no extra thread. |
|
||||||
|
| Hardware | None available. A protocol mock (`tools/mock_e1608.py`) is the contract. Commands whose layout rests on a single reference (or where the two disagree) are listed as unverified. |
|
||||||
|
| Analog input config | Full: per-channel enable and range, global SE/DIFF, MAC discovery, external clock, trigger, DI/counter poll rate. |
|
||||||
|
| Digital lines and counter | Plot channels the user chooses, off by default, enabled per line in the connect form. |
|
||||||
|
| Acquisition modes | Hardware-paced only (scan rate or external clock). Software-paced mode (`ScanRate = 0`) is not ported. |
|
||||||
|
| Structure | Three layers: link, device, source. The bottom two have no GTK or `DataSource` types. |
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. Files (new, `src/source/e1608/`, plus the mock)
|
||||||
|
|
||||||
|
- `e1608_link.{hpp,cpp}`: UDP discovery (broadcast, or a unicast sweep of a given subnet
|
||||||
|
matched by MAC and the E-1608 product id) and connection-code handshake; TCP command
|
||||||
|
framing with a timeout; the scan socket on `port + 1`. Plain POSIX sockets.
|
||||||
|
- `e1608_device.{hpp,cpp}`: typed commands (AI, AO, DIO read/write/direction, counter
|
||||||
|
read/reset, scan queue, scan start/stop, status), the factory calibration read from the board's
|
||||||
|
calibration memory, and `decode()`, which turns raw interleaved `uint16` scans into calibrated
|
||||||
|
volts (`double`).
|
||||||
|
- `e1608_source.{hpp,cpp}`: the `DataSource`: the `poll()` state machine, `Frame` building, the
|
||||||
|
DI/counter and generator timers, the atomic readouts for the panels.
|
||||||
|
- `e1608_kind.cpp`: the `SourceKind`: the connect form and the two panels.
|
||||||
|
- `tools/mock_e1608.py`: the protocol mock.
|
||||||
|
- `register_builtin_sources()` registers `e1608` after `udps`.
|
||||||
|
|
||||||
|
### 2. Data path and threading
|
||||||
|
|
||||||
|
Everything runs inside `poll()` on the producer thread. The GTK thread reaches the board only
|
||||||
|
through `post(Command)`.
|
||||||
|
|
||||||
|
`poll(timeout)`:
|
||||||
|
|
||||||
|
1. Drains the posted commands (DAC writes, DIO toggles, counter reset, generator edits).
|
||||||
|
2. Runs the connection state machine: optional MAC lookup, handshake, calibration read, DIO
|
||||||
|
direction and initial outputs, scan queue and scan start take Disconnected to Scanning. A
|
||||||
|
link error fires `on_event(Disconnected)` and retries every 2 s. A connect attempt can block
|
||||||
|
`poll()` for up to the command timeout; that delays only the command queue.
|
||||||
|
3. Waits on the scan socket for at most the time to the next timer, reads what arrived, keeps a
|
||||||
|
partial scan for the next call, decodes the complete scans with the calibration and
|
||||||
|
delivers a `Frame`.
|
||||||
|
4. Runs the due timers: the DI/counter poll (`DiPollRate`) is a command round trip; the
|
||||||
|
generator tick (at most 10 Hz) is a DAC write.
|
||||||
|
|
||||||
|
The frame:
|
||||||
|
|
||||||
|
- Signals: the enabled AI channels (unit `V`, `range_min`/`range_max` from the channel's range),
|
||||||
|
the DI lines the user chose (0/1), and the counter if chosen.
|
||||||
|
- All signals are scalar; `num_samples` is the number of scans decoded in that read.
|
||||||
|
- DI and counter values are held across the block (a step trace).
|
||||||
|
- `sampling_rate` is the scan rate on every signal. `counter` is a running frame count.
|
||||||
|
- The aggregate rate (enabled channels times scan rate) must stay at or below 250 kS/s. The
|
||||||
|
form refuses a configuration above it.
|
||||||
|
|
||||||
|
Overrun: if the board reports a scan overrun the source fires `on_event(Error)` and restarts
|
||||||
|
the scan when `AutoRestart` is set; the next frame carries `lost = 1`. This is approximate,
|
||||||
|
because the board does not report how many scans it dropped.
|
||||||
|
|
||||||
|
Panel readouts: the source keeps the live DIO inputs, the counter, the DAC levels and the
|
||||||
|
generator state in atomics. The panels read them from a GTK timer, so no widget touches the
|
||||||
|
source's internals.
|
||||||
|
|
||||||
|
### 3. Connect form and panels
|
||||||
|
|
||||||
|
Connect form: grouped `AdwExpanderRow`s in the chooser's group. `SourceConfig` stays flat; a
|
||||||
|
per-channel setting is a key such as `ai0=BIP10V` (absent means off).
|
||||||
|
|
||||||
|
- Connection: address, MAC, discovery subnet, port, connection code, timeout. `valid()` needs
|
||||||
|
an address, or a MAC plus a subnet.
|
||||||
|
- Acquisition: scan rate, external clock, trigger mode, auto-restart, calibrate (default on),
|
||||||
|
DI/counter poll rate.
|
||||||
|
- Analog inputs: global SE or DIFF mode and 8 channel rows (enable, range). In DIFF mode rows
|
||||||
|
4 to 7 are insensitive, not hidden. `valid()` also enforces the 250 kS/s aggregate.
|
||||||
|
- Digital I/O: direction per bit (I or O), a "plot" switch per input bit, a "plot counter"
|
||||||
|
switch, the initial DO value and the initial DAC levels.
|
||||||
|
|
||||||
|
`identity()` is the address, or the MAC when there is no address. A session file is named
|
||||||
|
`e1608-<identity>`, port 1, as sub-project 1 defines for non-UDPS kinds. `has_datagrams()` is
|
||||||
|
false, so the capture button is disabled.
|
||||||
|
|
||||||
|
Two side-stack panels, both insensitive while disconnected (disabled, not hidden):
|
||||||
|
|
||||||
|
- Analog out: per DAC, a mode (Level, Sine, Square, Triangle, Ramp), a level or amplitude plus
|
||||||
|
offset, a frequency of 0.1 to 10 Hz, and an on/off switch. It shows the last voltage written
|
||||||
|
and clamps to ±10 V. The generator stops on disconnect and the DACs return to their initial
|
||||||
|
levels on reconnect.
|
||||||
|
- Digital I/O: eight bit rows; an input bit shows a live LED, an output bit has a toggle that
|
||||||
|
posts a write. The counter shows its value and a Reset button. Directions come from the form
|
||||||
|
and are not changed at runtime.
|
||||||
|
|
||||||
|
Tab icons are chosen in the plan under the project's bundled-icon rules.
|
||||||
|
|
||||||
|
### 4. Verification
|
||||||
|
|
||||||
|
Mock (`tools/mock_e1608.py`): UDP discovery with a MAC and the connection-code handshake; TCP
|
||||||
|
commands for AI, AO, DIO, the counter, scan queue, scan start/stop, calibration memory and
|
||||||
|
status; a scan stream on port+1 at the requested rate with a sine per channel; switches to drop
|
||||||
|
the link after N seconds and to report an overrun. Its header lists every command whose layout
|
||||||
|
is unverified (single reference, or the references disagree).
|
||||||
|
|
||||||
|
Probe `UDPSCOPE_PROBE_E1608=<delay ms>`, run against the mock with `--source e1608`, extends the
|
||||||
|
probe/dump in `src/ui.cpp`. It checks:
|
||||||
|
|
||||||
|
- Frames arrive with the expected channel count and sample rate.
|
||||||
|
- The panels are present and insensitive while disconnected.
|
||||||
|
- A DAC write, a DIO toggle and a counter reset each reach the mock.
|
||||||
|
- A forced link drop leads to a reconnect and a resumed stream.
|
||||||
|
- The calibration is applied (the mock serves non-trivial coefficients).
|
||||||
|
|
||||||
|
A decode check needs no mock: a fixed raw buffer decoded against hand-computed volts.
|
||||||
|
|
||||||
|
Baselines are unchanged: the UDPS self-check reads as on `main`, exactly three build warnings,
|
||||||
|
no `Gtk-CRITICAL`.
|
||||||
|
|
||||||
|
Errors: a rejected handshake (bad connection code), no device found, timeouts and scan-socket
|
||||||
|
loss surface through `on_event(Error)` with a readable message and follow the reconnect cycle.
|
||||||
|
A rejected connection code stops the retries: the source stays in Error with that message until the user reconnects.
|
||||||
|
|
||||||
|
Docs: update `architecture.md`, `build-and-run.md`, `verification.md`, `probe-readings.md` and
|
||||||
|
`README.md`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Software-paced acquisition (`ScanRate = 0`).
|
||||||
|
- A generator faster than 10 Hz, and a dedicated I/O thread.
|
||||||
|
- Changing DIO directions at runtime.
|
||||||
|
- Per-channel SE/DIFF mixing (one global mode).
|
||||||
|
- Verification against a real board.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Source plugins: framework and UDPS port (sub-project 1 of 4)
|
||||||
|
|
||||||
|
Date: 2026-10-31
|
||||||
|
Branch: `feature/source-plugins`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
udpscope reads exactly one kind of source, a UDPStreamer stream through `deps/udpsclient`.
|
||||||
|
The goal is to accept several kinds of data source as plugins, each with its own config
|
||||||
|
dialog and, where the hardware offers it, its own UI (signal generator, digital I/O,
|
||||||
|
multiple timebases).
|
||||||
|
|
||||||
|
The work is split into four sub-projects, each with its own spec, plan and implementation:
|
||||||
|
|
||||||
|
1. **Plugin framework + UDPS client ported onto it** (this document).
|
||||||
|
2. Flat UDP plugin (standard MARTe2 UDPWriter stream; the wire format must be supplied or
|
||||||
|
located before that spec is written -- none was found in `~/workspace` or `~/Projects`).
|
||||||
|
3. MCC E-1608 plugin. The protocol code in `MCC_E1608_DataSource` is bound to MARTe2
|
||||||
|
(`BasicTCPSocket`, `StreamString`, `SingleThreadService`); it is ported to plain C++, not
|
||||||
|
linked against MARTe2. AI streaming, DIO and DAC/generator panels.
|
||||||
|
4. ABScope plugin. Needs a multi-timebase data path and a wire format: the ABScope design
|
||||||
|
only sketches the UDP header and `streamerd` is unwritten.
|
||||||
|
|
||||||
|
Sub-project 1 changes no UDPS behaviour. It is a refactor plus the hosting machinery the
|
||||||
|
later plugins need.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
| --- | --- |
|
||||||
|
| What "plugin" means | Compiled-in registry; the interface is kept clean enough for a `dlopen` loader to be added later (not built now). |
|
||||||
|
| Threading | `Acquisition` keeps the single producer thread and calls `source->poll()`. UI actions reach the source through a thread-safe command queue drained inside `poll()`. CLAUDE.md's two-thread rule holds. (A per-source I/O thread was considered and deferred.) |
|
||||||
|
| Source UI | Widget factory: a source returns `GtkWidget*` for its config form and each capability panel. |
|
||||||
|
| Timebases in the frame | The neutral frame is single-timebase. Sub-project 4 extends interface and data path together; this may break the interface once, accepted. |
|
||||||
|
| CLI / sessions | `--server/--port/...` keep meaning UDPS; `--source NAME` and `--source-opt k=v` added. Sessions keyed by kind + identity; legacy `host_port.toml` read as `udps`. |
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### 1. The source interface (new `src/source/`)
|
||||||
|
|
||||||
|
- **`SourceConfig`**: ordered string key/value map with typed getters. It is the stored form
|
||||||
|
(a TOML table), the `--source-opt` form, and what a config widget reads and writes.
|
||||||
|
- **`Frame`**: source-neutral, mirrors `udps_frame_t`: `counter`, `lost`, `late`,
|
||||||
|
`recv_time`, `publish_mode`, `num_samples`, `signals[]` (the existing `SignalInfo`) and
|
||||||
|
`values[]` (doubles). `signals` and `values` are pointers into the source's own buffers,
|
||||||
|
valid only for the duration of `on_data`; `Acquisition` copies what it keeps. The hole, backfill and time-base logic needs `counter/lost/late`; a
|
||||||
|
source with no counter supplies a running counter and `lost = 0`. `Frame::value(sig, k,
|
||||||
|
col)` replaces `udps_frame_value`.
|
||||||
|
- **`DataSource`**: the per-connection object.
|
||||||
|
- `open()`, `poll(timeout_ms)`, `close()`.
|
||||||
|
- `stats()` returns the counters now in `StreamStats`; `connected()` reports link state.
|
||||||
|
- `post(Command)`: thread-safe queue, drained inside `poll()`. `Command` is
|
||||||
|
`std::function<void()>`.
|
||||||
|
- `set_datagram_tap(fn)`: optional; lets UDP-based sources feed the raw-packet capture.
|
||||||
|
- Output goes to a `SourceSink` (`on_config`, `on_data`, `on_event`) called inside
|
||||||
|
`poll()`, as `udps_client` does today.
|
||||||
|
- **`SourceKind`**: factory and UI description of one plugin type: `id`, `display_name`,
|
||||||
|
`identity(cfg)`, `make(cfg)`, `make_config_form()` and `make_panels(source)` (each
|
||||||
|
`{id, title, GtkWidget*}`). `make_config_form()` takes no argument: the chooser calls
|
||||||
|
`form.write(cfg)` to fill the form. A `ConfigForm` is a list of `AdwPreferencesRow` widgets
|
||||||
|
plus `write` / `read` / `valid` / `on_change` closures, so the chooser can place the rows in
|
||||||
|
its own group and gate its Connect button on `valid()`.
|
||||||
|
- **`SourceRegistry`**: filled by an explicit `register_builtin_sources()` call. No
|
||||||
|
static-init self-registration (linkers can drop it). A future `dlopen` loader would only
|
||||||
|
call `register_kind()`.
|
||||||
|
|
||||||
|
### 2. Acquisition and the UDPS plugin
|
||||||
|
|
||||||
|
- `Acquisition` takes a `std::unique_ptr<DataSource>` instead of building a `udps_client_t`
|
||||||
|
and implements `SourceSink`; it creates the source lazily in `start()`, from the kind and
|
||||||
|
`SourceConfig` it was given; the four trampolines go away.
|
||||||
|
- `on_config` / `on_data` change only in the type they read. Time base, holes, backfill,
|
||||||
|
decimation, trigger and math code are untouched.
|
||||||
|
- `run()` calls `source_->poll(50)` and copies `source_->stats()` into
|
||||||
|
`ScopeModel::stats()`. `PacketCapture` stays in `Acquisition`, fed through the datagram
|
||||||
|
tap. Sources without datagrams (E-1608) leave the capture UI disabled, not hidden.
|
||||||
|
- `src/source/udps_source.cpp` wraps `deps/udpsclient` unchanged: it maps `udps_signal_t`
|
||||||
|
to `SignalInfo` and `udps_frame_t` to `Frame`, and carries the existing knobs (server,
|
||||||
|
port, multicast, interface, recv buffer). Identity is `host:port`.
|
||||||
|
- The `udps_*` types are used only in `acquisition.cpp` today, so no other file changes for
|
||||||
|
this step beyond the `Frame` type.
|
||||||
|
|
||||||
|
### 3. UI hosting, config and persistence
|
||||||
|
|
||||||
|
- **Connect dialog** (`source_dialog.cpp`): gains a "Source type" dropdown; choosing a kind
|
||||||
|
swaps in that kind's `make_config_form()`. The UDPS form (server, port, multicast,
|
||||||
|
interface) moves into the UDPS plugin. The dialog keeps the known-sources list, buffer
|
||||||
|
size, relative time and capture ring size.
|
||||||
|
- **Capability panels**: after the source is created the app calls `make_panels()` and adds
|
||||||
|
each panel as a page of the existing `side_stack` (the side area's page stack, owned by
|
||||||
|
the tab switcher). A panel is insensitive when
|
||||||
|
disconnected (disabled, not hidden; the tab switcher owns visibility). A source with no
|
||||||
|
panels adds no tab; UDPS has none. Panels call `source->post(Command)`, never the source
|
||||||
|
directly, so cross-thread safety lives in one place. A helper kit for common panels (DIO
|
||||||
|
bank, generator form) lives in `src/source/widgets/` but is only added when a plugin uses
|
||||||
|
it, so it arrives with sub-project 3.
|
||||||
|
- **Config / CLI**: `Config` gains `source_kind` (default `udps`) and a `SourceConfig`.
|
||||||
|
`--server/--port/--multicast/--interface` populate the UDPS entries. `--source NAME` and
|
||||||
|
repeatable `--source-opt k=v` work for any kind. `--help` lists the registered kinds.
|
||||||
|
- **Sessions**: the source's config is stored as top-level TOML keys, `source_kind` and
|
||||||
|
`source_opt_<key>`, written only for non-UDPS kinds (a UDPS session file is unchanged, so
|
||||||
|
legacy `host_port.toml` files read as `udps` with nothing migrated). It is not a `[source]`
|
||||||
|
table. A non-UDPS session file is named by a pseudo-host `<kind>-<identity>` with port 1.
|
||||||
|
- **Mocks**: `tools/mock_udpstreamer.py` remains the UDPS mock. Each later plugin ships its
|
||||||
|
own mock under `tools/` so the headless self-check keeps working.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- This is a pure refactor for UDPS: the existing headless self-check must read exactly as on
|
||||||
|
`main` -- exactly three build warnings, no `Gtk-CRITICAL` (see `docs/claude/verification.md`).
|
||||||
|
- New probe `UDPSCOPE_PROBE_SOURCE_KINDS` (`UDPSCOPE_PROBE_SOURCES` is already the chooser
|
||||||
|
discovery probe). Its value is a delay in ms, and its presence also registers the stub
|
||||||
|
`probe` kind. It extends the probe/dump in `src/ui.cpp` rather than
|
||||||
|
adding a harness. It checks that the registry lists its kinds, that a config form
|
||||||
|
round-trips its `SourceConfig`, and -- with a stub test-only kind carrying one panel --
|
||||||
|
that the panel's tab is built, insensitive while disconnected, and that a posted command is
|
||||||
|
drained by `poll()`.
|
||||||
|
- Document the new probe lines in `docs/claude/probe-readings.md`; update `architecture.md`
|
||||||
|
(a `DataSource` replaces the client in the thread description), `build-and-run.md`
|
||||||
|
(new CLI options) and `README.md`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Runtime `.so` loading, and a per-source I/O thread.
|
||||||
|
- Multi-timebase data path (sub-project 4).
|
||||||
|
- The flat UDP, E-1608 and ABScope plugins (sub-projects 2-4).
|
||||||
|
- The DIO / generator helper widgets (arrive with sub-project 3).
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# E-1608 network scan -- design
|
||||||
|
|
||||||
|
Date: 2026-11-02. Status: approved in conversation, awaiting written-spec review.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The E-1608 connection form gets a "Scan network" row that finds boards and fills the address or the
|
||||||
|
MAC from a click. Today a user must already know the address, or give a MAC and rely on a lookup.
|
||||||
|
|
||||||
|
## Finding (tested on a real board, 2026-11-02)
|
||||||
|
|
||||||
|
The board was at 169.254.88.75 on `net0`; the default route is `wlan0`. A `D` probe sent to
|
||||||
|
255.255.255.255 with no binding found nothing (it left through `wlan0`). The same probe sent with
|
||||||
|
`SO_BINDTODEVICE net0`, or bound to the interface address, or sent to a directed broadcast, or by
|
||||||
|
unicast, was answered (64 bytes, first byte `D`). So a single limited broadcast is not enough on a
|
||||||
|
host with several interfaces; the existing "find by MAC" lookup has the same flaw.
|
||||||
|
|
||||||
|
## Discovery layer (`e1608_link.{hpp,cpp}`)
|
||||||
|
|
||||||
|
- `class Scan` (non-blocking, one instance per scan): `Scan(port)` enumerates up, non-loopback IPv4
|
||||||
|
interfaces with `IFF_BROADCAST` (`getifaddrs`), opens one UDP socket per interface bound to it
|
||||||
|
(`SO_BINDTODEVICE`, falling back to binding the interface address if the call is refused), enables
|
||||||
|
`SO_BROADCAST`, and sends `D` to 255.255.255.255 on each. `poll()` drains every socket without
|
||||||
|
blocking and returns the devices found so far (E-1608 product id only, one entry per MAC, the same
|
||||||
|
reply parsing as `discover`). The caller owns the clock.
|
||||||
|
- `discover()` keeps its signature. A new overload or helper `discover_broadcast(port, wait_ms)` is
|
||||||
|
`Scan` plus a blocking wait. `E1608Source`'s lookup uses it when the subnet is empty; a given
|
||||||
|
subnet still uses `sweep_targets` + `discover` as today.
|
||||||
|
- An interface that cannot be opened or sent on is skipped, never an error; no interfaces is an empty
|
||||||
|
result.
|
||||||
|
|
||||||
|
## Form (`e1608_kind.cpp`)
|
||||||
|
|
||||||
|
- At the top of the Connection expander, an `AdwActionRow` "Scan network" with a text suffix button
|
||||||
|
"Scan" (widget name `e1608-scan`). The row subtitle reads "Scanning...", "N boards found" or
|
||||||
|
"No board found". No theme icons.
|
||||||
|
- A scan starts a `Scan` on the port in the Port field and a 50 ms `g_timeout_add` poll for about
|
||||||
|
1.5 s. There is no thread: the project has exactly two. The timer is removed when the form is
|
||||||
|
destroyed (`Form::alive` guard plus the destroy hook) and when a new scan starts.
|
||||||
|
- Each device found becomes an `AdwActionRow` in the Connection expander right after the scan row,
|
||||||
|
replacing the previous results. Title: IP. Subtitle: MAC. Activating the row fills Address and
|
||||||
|
clears MAC and Subnet. A suffix text button "Use MAC" (`e1608-use-mac`) fills MAC and clears
|
||||||
|
Address and Subnet. Fill is done under the form's `updating` guard, then `changed` fires once.
|
||||||
|
- Result rows are not read or written by the session: only the filled fields persist.
|
||||||
|
- The description line mentions the scan.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- A scan probe (env var in the existing pattern): starts against `tools/mock_e1608.py` on a
|
||||||
|
loopback-reachable port, presses the scan button, and prints `[e1608] scan: found=N ip=... mac=...
|
||||||
|
fill_addr=1 fill_mac=1 ok=1`, covering both fills and that the results are replaced on a rescan.
|
||||||
|
If the sandbox refuses the broadcast, the line says so and the limitation is documented, not claimed.
|
||||||
|
- A manual run of the scan against the real board, recorded in the README.
|
||||||
|
- Baselines unchanged: 3 warnings clean, `gtk warnings: 0`, no `Gtk-CRITICAL`.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
`README.md` (E-1608 paragraph), `docs/claude/{build-and-run,verification,probe-readings,architecture}.md`.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
Remembering scan results across runs; scanning other subnets than the host's interfaces (the
|
||||||
|
`subnet` sweep field remains for that); a worker thread.
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
# Flat UDP source plugin (sub-project 2 of 4)
|
||||||
|
|
||||||
|
Date: 2026-11-02
|
||||||
|
Branch: to be cut from `feature/source-plugins`
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Sub-project 1 added the `DataSource` / `SourceKind` / `SourceRegistry` framework. Sub-project 3
|
||||||
|
added the E-1608. This sub-project adds `flatudp`: a receiver for the MARTe2 `UDPSender`
|
||||||
|
datasource (`MARTe2-components/Source/Components/DataSources/UDP/UDPSender.{h,cpp}`).
|
||||||
|
|
||||||
|
## The wire format
|
||||||
|
|
||||||
|
Read from `UDPSender::Synchronise()` and `MemoryDataSourceI`:
|
||||||
|
|
||||||
|
- One `Write` of the datasource memory per `Synchronise()`: **one datagram is one cycle**.
|
||||||
|
- The memory is the signals concatenated in declaration order, **no padding**, in the sender's
|
||||||
|
native byte order. `signalOffsets[s]` is the running sum of `size * elements`.
|
||||||
|
- There is no header, counter, timestamp or signal description. In IndependentThread mode the
|
||||||
|
first signal is a `uint8` `Trigger`; it is sent like any other signal.
|
||||||
|
- The user must therefore enter the exact signal list by hand.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
| Question | Decision |
|
||||||
|
| --- | --- |
|
||||||
|
| Time base | The user declares the nominal cycle rate in Hz (`rate`, required). The existing pipeline stamps the frames: anchored at arrival, samples spaced by the declared cadence, corrected to the measured cadence when the arrivals are more than 25% off it; `--relative-time` gives a purely counted clock. There is no `time_mode` switch: `acquisition.cpp` has no stamping path that ignores the declared cadence, and `sampling_rate = 0` would size the ring and the ingest decimation for 1 kHz (`model.cpp:60`). Loss is never reported (`lost = 0`). |
|
||||||
|
| Signal list entry | A row editor: name entry, type dropdown, element count and a remove button per row, plus an "Add signal" row. |
|
||||||
|
| Network | Port and optional bind address, plus an optional multicast group and interface. No source-address filter. |
|
||||||
|
| Byte order | `little` (default) or `big`. |
|
||||||
|
| Out of scope | Auto-detecting the list, importing a MARTe2 `.cfg`, a source filter, loss detection. |
|
||||||
|
|
||||||
|
## Config keys
|
||||||
|
|
||||||
|
Persisted in the session, settable with `--source-opt`. Kind id: `flatudp`.
|
||||||
|
|
||||||
|
| Key | Default | Meaning |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `port` | 44488 | UDP port to listen on (the sender's own default). |
|
||||||
|
| `bind` | empty | Local address to bind; empty is any. |
|
||||||
|
| `multicast` | empty | Group to join; empty is plain unicast. |
|
||||||
|
| `iface` | empty | Local interface address for the join. |
|
||||||
|
| `rate` | none | Nominal cycle rate in Hz. Required; sizes the ring and the decimation and spaces samples within a frame. |
|
||||||
|
| `endian` | `little` | `little` or `big`. |
|
||||||
|
| `signals` | none | `name:type[count],...`, e.g. `Trigger:uint8,Ia:float32,Wave:float32[16]`. A persistence format only: the form builds and parses it. |
|
||||||
|
|
||||||
|
Types: `uint8/16/32/64`, `int8/16/32/64`, `float32`, `float64`. Count 1 is a scalar signal; N
|
||||||
|
is a vector signal. Expected datagram size is the sum of `size * count` over the rows.
|
||||||
|
|
||||||
|
Validity (form and `--source-opt` alike): at least one signal; names non-empty and unique;
|
||||||
|
count 1-4096; total size at most 65507 bytes; `rate` finite and positive.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
New files under `src/source/flatudp/`:
|
||||||
|
|
||||||
|
- `flatudp_signals.{hpp,cpp}` -- the `signals` string codec: parse, format, validate, size
|
||||||
|
arithmetic. No GTK, so a probe can round-trip it.
|
||||||
|
- `flatudp_source.{hpp,cpp}` -- the `DataSource`.
|
||||||
|
- `flatudp_kind.{hpp,cpp}` -- the `SourceKind`, the form, and the registration in
|
||||||
|
`register_builtin_sources()`.
|
||||||
|
|
||||||
|
Threading is unchanged: the source lives on the producer thread and `poll()` does all the work.
|
||||||
|
|
||||||
|
### Data flow
|
||||||
|
|
||||||
|
`poll(timeout_ms)` waits on a non-blocking UDP socket and drains it.
|
||||||
|
|
||||||
|
- A datagram of exactly the expected size is decoded row by row to doubles (byte-swapped when
|
||||||
|
`endian=big`).
|
||||||
|
- **Batching.** When every row is scalar, decoded datagrams are queued and delivered
|
||||||
|
`block` at a time in one `Frame` (`num_samples = block`), where
|
||||||
|
`block = clamp(round(rate / 100), 1, 1024)`, about 100 frames per second as the E-1608 does. A
|
||||||
|
partial block is flushed after 100 ms so a slow or stopped sender does not hold samples back.
|
||||||
|
When any row is a vector, a vector signal carries one sample per `Frame`, so each datagram is
|
||||||
|
its own frame (`num_samples = 1`).
|
||||||
|
- `Frame`: `counter` a running frame count, `lost = 0`, `late = false`, `recv_time` the arrival
|
||||||
|
clock at delivery.
|
||||||
|
- `set_datagram_tap` is honoured for every datagram, valid or not, and `has_datagrams()` is
|
||||||
|
true, so packet capture works.
|
||||||
|
- `on_config` is called from the first `poll()` with one `SignalInfo` per row (`rows = count`,
|
||||||
|
`cols = 1`, `time_mode = kTimePacket`, `sampling_rate = rate`).
|
||||||
|
|
||||||
|
### Errors and state
|
||||||
|
|
||||||
|
- A wrong-size datagram is dropped, never decoded. Each distinct size raises one `Error`
|
||||||
|
event ("datagram of N bytes, expected M, check the signal list") and increments `stale`.
|
||||||
|
- `Connected` is raised on the first valid datagram and `Disconnected` after 2 s of silence; a
|
||||||
|
later datagram raises `Connected` again.
|
||||||
|
- A bind or multicast-join failure raises an `Error` and the source stays disconnected.
|
||||||
|
|
||||||
|
### Connect form
|
||||||
|
|
||||||
|
Built from `AdwPreferencesRow`s, with the destroy-notify guard used by the E-1608 form.
|
||||||
|
|
||||||
|
- Network group: port, bind, multicast, interface.
|
||||||
|
- Timing: a rate spin.
|
||||||
|
- Byte order combo.
|
||||||
|
- Signals group: one row per signal, "Add signal" at the bottom, and a live subtitle with the
|
||||||
|
implied size ("41 bytes per datagram"). `valid()` is false while the list breaks a validity
|
||||||
|
rule.
|
||||||
|
- The `signals` string is rebuilt from the rows on every change; `write()` parses it back.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
No unit suite; the self-check is the contract.
|
||||||
|
|
||||||
|
- `tools/mock_flatudp.py`: emits packed structs for a given signal spec and rate; options for
|
||||||
|
big-endian and for one wrong-size datagram.
|
||||||
|
- `UDPSCOPE_PROBE_FLATUDP` in `src/ui_probe.cpp`: stream names and count; a hand-computed
|
||||||
|
decode of known values including a vector signal and a `uint64`; the declared rate reaching the model; the
|
||||||
|
wrong-size error reported once and counted in `stale`; disconnect after silence; the form's
|
||||||
|
validity rules; the `signals` codec round trip. A multicast loopback check runs if the
|
||||||
|
sandbox allows it.
|
||||||
|
- Baselines unchanged: exactly 3 build warnings, `gtk warnings: 0`, no `Gtk-CRITICAL`.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
README (the kind and its limits: no loss detection, the signal list is the user's
|
||||||
|
responsibility), `docs/claude/build-and-run.md` (options and mock usage),
|
||||||
|
`docs/claude/architecture.md` (source plugins), `docs/claude/probe-readings.md` and
|
||||||
|
`docs/claude/verification.md` (the new probe lines).
|
||||||
|
|
||||||
|
## Amendment 2026-11-02: how a vector signal is shown (decided with the user during Task 2)
|
||||||
|
|
||||||
|
Task 2 found that treating every vector row as one channel of `count` consecutive samples zero-pads
|
||||||
|
the scalar channels of a mixed list (the model spans a frame by its largest element block, and
|
||||||
|
`Frame::value` is 0 past a scalar's one value) and declares the wrong rate for the vector channel.
|
||||||
|
Both readings of a vector are useful depending on the sender, so each vector row picks one:
|
||||||
|
|
||||||
|
- **`channels` (default).** `Wave:float32[16]` becomes 16 scalar channels named `Wave[0]` .. `Wave[15]`,
|
||||||
|
one sample per cycle each. All such lists are batched like an all-scalar list.
|
||||||
|
- **`waveform`.** `Wave:float32[16]w` is ONE channel receiving a block of `count` consecutive samples per
|
||||||
|
cycle: `SignalInfo{rows=count, cols=1, sampling_rate=rate*count}`, one datagram per `Frame`
|
||||||
|
(`num_samples = 1`).
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- The `signals` string takes an optional trailing `w` after the count; `format` emits it only for a waveform row.
|
||||||
|
- A waveform row needs `count > 1`.
|
||||||
|
- A list with a waveform row must contain only waveform rows, all with the same `count`; otherwise it is
|
||||||
|
invalid, with a message the form shows. (No zero-padding, no holding scalars across a block.)
|
||||||
|
- The channel total is capped at 256: scalars count 1, a `channels` row counts `count`, a waveform row counts 1.
|
||||||
|
Over the cap is invalid.
|
||||||
|
- `block` batching applies whenever the list has no waveform row; with a waveform row it is 1.
|
||||||
|
- The form shows a "Vector as" dropdown (`Channels` / `Waveform`) on rows whose count is above 1, and
|
||||||
|
the channel total in the subtitle.
|
||||||
|
- The earlier "Data flow" text saying every vector row forces `num_samples = 1` applies to waveform rows only.
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Badge bar, readouts dock and slide-over panel -- design
|
||||||
|
|
||||||
|
Date: 2026-11-02. Status: approved in conversation, awaiting written-spec review.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the sidebar's tab switcher and the separate events pane with a layout built the way bench
|
||||||
|
scopes build theirs: setup is reached by *the object it configures* (a badge under the plot), results
|
||||||
|
are always on screen (a compact dock), and anything bigger than a readout opens in one slide-over
|
||||||
|
panel. The sidebar tabs and the left events pane go away.
|
||||||
|
|
||||||
|
## Why (the problems, as found in the code)
|
||||||
|
|
||||||
|
- **Unreadable tab titles.** `side_view` is pinned at 320 px (`min-sidebar-width` = `max-sidebar-width`
|
||||||
|
in `main-window.ui`), so four text tabs ("Measure / Signals / Analog out / Digital I/O") ellipsize,
|
||||||
|
and a source can add more.
|
||||||
|
- **Hard to find things.** The Signals tab stacks a trigger form of up to 15 rows (plus two
|
||||||
|
expanders) on top of the signal list. Cursors and measurements are in Measure, source controls are
|
||||||
|
in their own tabs, the header is icon-only, and the trigger/cursor toggles live apart from their
|
||||||
|
panels.
|
||||||
|
- **The events pane is a different application.** A `GtkPaned` + `GtkRevealer` on the left with its
|
||||||
|
own bold title and header, a dragged width that is not saved, thumbnails the rest of the UI does
|
||||||
|
not have, and a header toggle that is hidden until events exist.
|
||||||
|
|
||||||
|
## State of the art this follows
|
||||||
|
|
||||||
|
- Tektronix 5/6 Series: a badge bar under the waveform (a badge per channel/math waveform, plus
|
||||||
|
Horizontal, Trigger, Acquisition badges); a tap opens *only that item's* settings; results
|
||||||
|
(measurements, cursors, search) are a separate bar that does not cover the waveform.
|
||||||
|
- Scopy and PicoScope 7: a channel list at the edge and a settings panel that slides in for the
|
||||||
|
selected item (PicoScope lets it overlay or squeeze the trace).
|
||||||
|
- Saleae Logic 2: one side panel at a time; decoded results also in a list beside the waveform.
|
||||||
|
- Keysight InfiniiVision: dialogs that can be undocked so cursor and measurement values show together.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌ header: [window 2 s ▾] [Trigger] [Pause] [Cursors] [Dock] [☰] ┐
|
||||||
|
├──────────────────────────────────────────────┬────────────────────┤
|
||||||
|
│ plot rows ┌───────────┐ │ READOUTS │
|
||||||
|
│ │ slide-over│ │ cursors, measures │
|
||||||
|
│ │ settings │ ├────────────────────┤
|
||||||
|
│ └───────────┘ │ EVENTS (n) │
|
||||||
|
├───────────────────────────────────────────────┴────────────────────┤
|
||||||
|
│ [●sine][●square][+] … [Trigger ▸][Source ▸]│
|
||||||
|
├ status line ───────────────────────────────────────────────────────┤
|
||||||
|
```
|
||||||
|
|
||||||
|
### Header bar
|
||||||
|
|
||||||
|
Keeps the time-window control, the Trigger, Pause and Cursors toggles and the overflow menu. The
|
||||||
|
Events toggle is removed (Events is always in the dock). The sidebar toggle becomes the **Dock**
|
||||||
|
toggle, so a narrow window can still hide the dock. There is no "Window" badge: the header's control
|
||||||
|
is the timebase, and a second one would duplicate it.
|
||||||
|
|
||||||
|
### Right dock ("Readouts" and "Events")
|
||||||
|
|
||||||
|
- Fixed width, about 280 px; always on screen, shown but insensitive until the stream has signals
|
||||||
|
("disable, do not hide"). It stays an `AdwOverlaySplitView` sidebar, which already gives it its
|
||||||
|
own fixed width, the hide toggle and the overlay behaviour on a narrow window
|
||||||
|
(`invariants-plot-and-layout.md`).
|
||||||
|
- **Readouts** (top): the active cursor values and measurement values, read-only, one compact line per
|
||||||
|
value, through the one formatter both panes share (`invariants-readouts-and-cursors.md`).
|
||||||
|
An "Edit" affordance opens the slide-over's `measure` or `cursors` page; clicking a measurement row
|
||||||
|
opens that measurement's editor.
|
||||||
|
- **Events** (bottom): compact rows -- title, time, a small marker; no thumbnails. Insensitive outside
|
||||||
|
trigger mode. A "Manage" affordance opens the slide-over's `events` page.
|
||||||
|
|
||||||
|
### Badge bar
|
||||||
|
|
||||||
|
A fixed-height row between the plot and the status line.
|
||||||
|
|
||||||
|
- **Signals** (left): one badge per enabled signal, up to 6; beyond that a single "N signals" badge.
|
||||||
|
A badge shows a colour dot and the name, is a drag handle (reorder, or drop on a plot row, which
|
||||||
|
replaces today's drag from the signal list), and opens that signal's page (colour, calibration,
|
||||||
|
row, math). A trailing `+` opens the `signals` page (the full list, to enable more; the math editor
|
||||||
|
entry).
|
||||||
|
- **System** (right): **Trigger** shows its state (off / armed / triggered) and opens the trigger
|
||||||
|
form; **Source** shows the connection state and the source name and opens that source's pages
|
||||||
|
(`SourcePanel` pages, behind a small segmented switch inside the panel when there are several, as
|
||||||
|
E-1608 has Analog out and Digital I/O).
|
||||||
|
|
||||||
|
### Slide-over panel
|
||||||
|
|
||||||
|
- Overlays the plot's right edge (about 360 px wide), never the dock; one page at a time; Esc or a
|
||||||
|
click outside closes it. The plot is not resized by it.
|
||||||
|
- Pages, by id: `trigger`, `signal:<name>`, `signals`, `measure`, `cursors`, `events`,
|
||||||
|
`source-<id>`. Each page has its own scroller, so a tall page does not leave a shorter one scrolled
|
||||||
|
(the reason the sidebar had a scroller per page).
|
||||||
|
- Content: the trigger form (conditional rows, Advanced, Search the buffer), signal and calibration
|
||||||
|
settings, measurement add/edit/remove and statistics, cursor options, event actions (Live, Compare,
|
||||||
|
Accumulate), the math editor entry, and source pages.
|
||||||
|
|
||||||
|
### Status line
|
||||||
|
|
||||||
|
Unchanged.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
New files, because `ui.cpp` is already very large.
|
||||||
|
|
||||||
|
| Unit | File | Responsibility |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `BadgeBar` | `ui_badges.cpp` | Builds signal / Trigger / Source badges from the model; emits "open page X"; owns badge drag. |
|
||||||
|
| `SlideOver` | `ui_slideover.cpp` | A `GtkOverlay` child of the plot area: revealer + page stack keyed by id; Esc/click-away; open/close API. |
|
||||||
|
| `ReadoutsDock` | `ui_dock.cpp` | The compact readouts and the compact events list, reading the same model the panes read today. |
|
||||||
|
|
||||||
|
Reuse, not rewrite:
|
||||||
|
|
||||||
|
- The trigger form, signal list and calibration, measurement editor and cursor options are
|
||||||
|
**reparented** into slide-over pages.
|
||||||
|
- `SourcePanel{id, title, widget}` is unchanged, so the E-1608 and Flat UDP panels need no change.
|
||||||
|
- Only the events *list and actions* of the old pane move; the left `GtkPaned`/`GtkRevealer` and its
|
||||||
|
divider sync are deleted.
|
||||||
|
|
||||||
|
## Invariants kept
|
||||||
|
|
||||||
|
- **Layout must not depend on content** (`invariants-plot-and-layout.md`): the dock has a fixed width,
|
||||||
|
the badge bar a fixed height, and the slide-over overlays instead of resizing the plot. Rows that
|
||||||
|
appear by trigger condition appear inside the slide-over's scroller.
|
||||||
|
- **A `GtkOverlay` child is picked wherever its allocation is**: the slide-over is one opaque widget
|
||||||
|
aligned to the end edge by margin and width, never a full-area transparent child, so pan, drop and
|
||||||
|
hover on the rest of the plot are untouched.
|
||||||
|
- The header's Trigger/Cursors/Pause toggles keep their two-way mirroring with the panels.
|
||||||
|
|
||||||
|
## State and session file
|
||||||
|
|
||||||
|
- The session file drops `side_tab` and gains the dock's shown state and width. An old file's
|
||||||
|
`side_tab` is ignored (an unknown name is already harmless to `select_side_tab`).
|
||||||
|
- The open slide-over page is not saved.
|
||||||
|
- The tab ids `measure`, `signals` and `source-<id>` stop being a session format; they remain page ids.
|
||||||
|
|
||||||
|
## What must be rewritten around it
|
||||||
|
|
||||||
|
- **Tour and context help:** steps and help-figure surfaces that point at `side_switcher`,
|
||||||
|
`side_stack` and `side_box` (`ui_tour.cpp`, `ui_context_help.cpp`) are re-pointed at the badge bar,
|
||||||
|
slide-over and dock, and the tour text is rewritten.
|
||||||
|
- **Probes:** the `switcher: policy=... pills=... labels=... images=... pages=...` line and
|
||||||
|
`side_tab_probe` become probes that count badges, dock rows and the open slide-over page; the events
|
||||||
|
divider probe goes away with the divider.
|
||||||
|
- **Keyboard:** `SideTabMeasure` / `SideTabSignals` become "open the measure / signals page".
|
||||||
|
- **Docs:** `README.md`, `USER-GUIDE.md`, `docs/claude/*` (architecture, verification,
|
||||||
|
probe-readings, the plot-and-layout and gtk-and-chrome invariants), and the Mallard manual's
|
||||||
|
figures.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
Four steps, each its own plan, each leaving the app building, passing the probes and usable.
|
||||||
|
|
||||||
|
1. **Shell.** The plot overlay, the badge bar with Trigger and Source, the slide-over holding the
|
||||||
|
existing trigger form and source pages. The sidebar tabs are removed; the existing Measure and
|
||||||
|
Signals panels move into a temporary dock so nothing is lost.
|
||||||
|
2. **Signal badges.** Per-signal badges, the `+` and "N signals" overflow, drag to reorder and to
|
||||||
|
move to a row.
|
||||||
|
3. **Compact readouts.** Read-only cursors and measurements in the dock; their editors in the
|
||||||
|
slide-over.
|
||||||
|
4. **Events.** The compact events section in the dock, the left pane and divider deleted, then the
|
||||||
|
tour / help / docs sweep.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Overlay above the GL plot.** The panel must composite and take input correctly above the
|
||||||
|
plotter's GL area. The layout editor already places overlay children over the plot, so this is
|
||||||
|
expected to work; step 1 confirms it before anything else is built on it.
|
||||||
|
- **Reparenting the trigger form.** Its two-way mirroring with the header toggle, `sync_trigger_rows`
|
||||||
|
and the locked summary view must keep working from a hidden-by-default page. Step 1 verifies it with
|
||||||
|
the existing trigger probes.
|
||||||
|
- **Many signals.** The badge bar's six-badge limit and horizontal behaviour are the one place the
|
||||||
|
E-1608 (up to about 17 signals) stresses the design; step 2 includes a run on it.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
User-arranged or undockable panels, saved layouts of the panels themselves, a touch-specific mode,
|
||||||
|
and any change to the plot, trigger, measurement or source engines. Per-source tab names do not
|
||||||
|
change.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Horizontal plot split -- design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let each row of the stacked plots be split into several plots side by side. All plots share one
|
||||||
|
time window (one x link, one zoom/pan, one crosshair). A split only arranges more plots on screen; it
|
||||||
|
is not an independent time window and not an XY plot.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Shared time window.** Cells in a row, and rows in the figure, are all members of the one x link
|
||||||
|
group. No new time semantics.
|
||||||
|
2. **Per-row split.** Each figure row holds 1..4 cells, independently of the other rows.
|
||||||
|
3. **Draggable cell widths**, saved with the session, mirroring row heights (`weight`).
|
||||||
|
4. **Caps.** At most 4 cells per row and at most 8 plots in total (`RowSet::kMaxRows` keeps its value
|
||||||
|
and now bounds plots, not figure rows). Every per-plot structure sized for 8 stays as is.
|
||||||
|
5. **Approach.** Extend the vendored plotter's `Figure` so a figure row holds several plots; the host
|
||||||
|
treats each cell as one plot. Rejected: several `Figure`/`PlotArea`s linked by hand (cannot express a
|
||||||
|
per-row split, reintroduces manual x/crosshair sync) and a general tiling tree (not asked for).
|
||||||
|
|
||||||
|
## Plotter (`deps/plotter`)
|
||||||
|
|
||||||
|
- `Figure` is constructed from cells-per-row (e.g. `{1,2,1}`). `Figure(u32 rows)` stays and means one
|
||||||
|
cell per row, so existing behaviour and `tests/test_figure.cpp` are unchanged.
|
||||||
|
- `plot(i)` indexes the flattened cells, top to bottom, left to right. `sync_x_links()` and the
|
||||||
|
crosshair already work on any set of plots and need no change.
|
||||||
|
- New: `set_cell_weights(row, weights)` and `set_col_gap(px)`. Widths use the height scheme: a floor
|
||||||
|
per cell, the weights share the rest, the last cell closes the row exactly (no seam).
|
||||||
|
- `FigureLayout` gains `cells` (one `Rect` per plot, flattened). `rows` stays the full-width rect per
|
||||||
|
figure row.
|
||||||
|
- Only the bottom figure row's cells carry x labels and the x title; other cells get `XStrip::None`.
|
||||||
|
- The left y gutter stays one common maximum for the whole figure with the same reserved floor. A
|
||||||
|
per-column gutter would make cell width depend on content, which the layout rule forbids.
|
||||||
|
- A figure too narrow for the floor widths shrinks the cells proportionally and drops the column gap,
|
||||||
|
as heights do; no rect is ever negative.
|
||||||
|
- Pointer hit-testing finds the plot under the cursor by its cell rect.
|
||||||
|
- The change is documented in `docs/claude/build-and-run.md` with the other vendored changes.
|
||||||
|
|
||||||
|
## Host (`src/plot_host.*`, `src/ui_layout.cpp`, `src/layout_store.*`, `src/ui.cpp`)
|
||||||
|
|
||||||
|
- Everything keyed by a plot index keeps meaning "flattened plot index": cursors, measurements,
|
||||||
|
`bind()`, readouts, persistence layers, `cursors_pane_bound`.
|
||||||
|
- `RowSet` gains `cells_per_row_` and per-figure-row cell weights, re-applied after every rebuild like
|
||||||
|
`row_weights_`. `split_cell(i)` / `remove_cell(i)` change a row's cell count; a removed cell drops its
|
||||||
|
traces, a new cell starts empty. `insert_row` / `remove_row` act on figure rows and carry their cells.
|
||||||
|
- `row_count()` stays the plot count; `figure_row_count()` is added. The plan may rename if this reads
|
||||||
|
badly.
|
||||||
|
- Cursors: the vertical pair is one shared time drawn in every cell; horizontal rulers are per cell.
|
||||||
|
The crosshair is one x on every cell, the hovered cell shows its y.
|
||||||
|
- Y axes are per cell (auto or manual); fit-all fits each; wheel and axis drags act on the cell under
|
||||||
|
the pointer; an x gesture anywhere moves the shared window.
|
||||||
|
- A signal dropped on a cell lands in that cell with the same slot rule as a row. The signals page's
|
||||||
|
"place on row" choice lists cells ("row 2, left") only for split rows.
|
||||||
|
- Persistence layers fill free slots of the same cell.
|
||||||
|
|
||||||
|
## Layout editor
|
||||||
|
|
||||||
|
Mirrors the row controls:
|
||||||
|
|
||||||
|
- Each vertical boundary between two cells gets a `+` (insert a cell) and a drag arrow (resize the two
|
||||||
|
neighbours). Each row's left and right edge gets a `+`, so a one-cell row can be split from either side.
|
||||||
|
- Each cell keeps its top-left `×`: in a multi-cell row it removes the cell; in a one-cell row it
|
||||||
|
removes the row, as today. Insensitive when it would leave no plot.
|
||||||
|
- Cell `+` is insensitive at 4 cells in the row or 8 plots in total; the row `+` is insensitive at 8
|
||||||
|
plots in total.
|
||||||
|
- A divider drag moves the two neighbouring widths, each floored at about 96 px logical, relative to the
|
||||||
|
weights at drag start (as the row drag is). Saved on drag end.
|
||||||
|
- Outside the editor nothing changes except the extra plots.
|
||||||
|
|
||||||
|
## Session file
|
||||||
|
|
||||||
|
`[[plot]]` tables gain `row` (the figure row) and `width` (the cell's weight). A session without `row`
|
||||||
|
loads as one cell per row, so existing sessions open unchanged. Row `weight` stays per figure row (carried
|
||||||
|
on the row's first cell).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/test_figure.cpp`: `{1}` identical to today; `{1,2}` tiles exactly; weights and floors respected;
|
||||||
|
a narrow figure degrades without negative rects; x link and crosshair span all cells.
|
||||||
|
- New headless probe `UDPSCOPE_PROBE_SPLIT_MS`: split a row, drop a signal in each cell, drag a divider;
|
||||||
|
check the cells tile the width, the shared x range is identical, both vertical cursors line up in every
|
||||||
|
cell, widths survive a session round-trip, and a session without `row` still loads. Expect
|
||||||
|
`gtk warnings: 0`, no `Gtk-CRITICAL`.
|
||||||
|
- Regression: layout, cursors, measurements, readouts and E-1608 probes read as before; the baseline file
|
||||||
|
is re-baselined only where this feature explains a difference.
|
||||||
|
- Layout dumps and probes that print per-row layout gain a cells list and a column-weights line.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
`README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/` (layout page), `docs/claude/probe-readings.md`,
|
||||||
|
`docs/claude/verification.md`, `docs/claude/invariants-plot-and-layout.md` (cell rules),
|
||||||
|
`docs/claude/build-and-run.md` (plotter change), and `ui_context_help.cpp` where the layout editor is described.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Split rows share one y axis -- design
|
||||||
|
|
||||||
|
Amends `2026-11-03-horizontal-split-design.md`. Where the two disagree, this one governs.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
The cells of a split row share one y range and draw one y axis, the way the rows of the figure share
|
||||||
|
one x window and draw one x axis. A split row reads as one wide plot with dividers.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Always shared, no option.** All cells of a figure row have one y range. An unsplit row is
|
||||||
|
unaffected.
|
||||||
|
2. **One y axis per row.** Only the leftmost cell has a y gutter and y labels. The other cells have
|
||||||
|
none, as only the bottom row carries x labels.
|
||||||
|
3. **Rulers are per row.** The two horizontal rulers (cursor pair) belong to the figure row and are
|
||||||
|
drawn at the same y in every cell. A drag in any cell moves them.
|
||||||
|
4. **Approach.** Extend the vendored plotter with a y link, symmetric to the x link. Rejected: syncing
|
||||||
|
the ranges from the host every frame (the plotter's own per-plot autoscale and the controller's
|
||||||
|
gestures would fight it, which is the problem the x link already solves).
|
||||||
|
|
||||||
|
## Plotter (`deps/plotter`)
|
||||||
|
|
||||||
|
- `Figure::set_share_row_y(bool)`, default off so existing figures and `tests/test_figure.cpp` are
|
||||||
|
unchanged. When on, the cells of each figure row form an implicit y link group. The group is rebuilt
|
||||||
|
with the cells.
|
||||||
|
- A y link syncs like the x link: in `layout()` a new `sync_y_links()` adopts a member's changed
|
||||||
|
`y_range` as the group range and pushes it to the other members. Wheel, pan, box zoom and axis drag
|
||||||
|
go through `set_y_range`, so they need no controller change.
|
||||||
|
- Auto scale: a member's own autoscale is suppressed (as `Axis::link_member` already blocks `Live`).
|
||||||
|
The group owns the auto flag and fits to the union of its members' visible traces; cells with no
|
||||||
|
trace are ignored; a log axis takes the union in axis space. `fit_all` and double-click fit the
|
||||||
|
group, not one cell. A manual `set_y_range` on a member clears the group's auto flag.
|
||||||
|
- `YStrip {Both, None}` and `Plot::set_y_strip_mode`, mirroring `XStrip`. `None` forces the y label
|
||||||
|
width, title width and reserved floor to 0. `Figure::layout` sets `None` on every cell but the
|
||||||
|
first of its row, structurally from the column index, never from content. `None` cells are skipped
|
||||||
|
in the common-gutter maximum.
|
||||||
|
- Cell widths: the row's y gutter is taken off the row width first, then the cells share the rest by
|
||||||
|
the existing `floor + extra*w/total` rule. Equal weights therefore give equal plot areas; the first
|
||||||
|
cell's rect is gutter plus its plot width, and the cell rects still tile the row exactly. The
|
||||||
|
floors apply to plot widths.
|
||||||
|
- A y axis-drag can only start in the leftmost cell (the others have no label area).
|
||||||
|
- Documented in `docs/claude/build-and-run.md` with the other vendored changes.
|
||||||
|
|
||||||
|
## Host (`src/plot_host.*`, `src/ui*.cpp`)
|
||||||
|
|
||||||
|
- `set_y_auto`, `set_y_range`, `fit_all`, and the Auto/Fixed dialog act on the figure row of the plot
|
||||||
|
they are given. The dialog reads the row's range.
|
||||||
|
- Rulers: one pair per figure row, stored on the row's first plot index and placed in every cell by
|
||||||
|
`place_cursors`; `sync_cursors` reads the dragged one back; `reset_cursors` and the measure level
|
||||||
|
marks use the row's range. The cursors pane lists the pair once per figure row.
|
||||||
|
- `row_unit` joins the units over all cells of the row, "" if mixed.
|
||||||
|
- `sync_compare_axes` is unchanged: it writes x and y once per `RowSet` row. The comparison panes are flat single-cell rows, so that is once per figure row and no y sharing is involved.
|
||||||
|
- `rebuild_all` turns the sharing on for the new `Figure` before the cells are populated.
|
||||||
|
- Readouts and measurements stay per trace and per plot index; only the axis is shared.
|
||||||
|
- Session file: unchanged (y ranges were never persisted).
|
||||||
|
|
||||||
|
## Layout editor
|
||||||
|
|
||||||
|
Divider drag maps the pointer to weights through plot widths, not cell widths: for the first cell,
|
||||||
|
subtract the row's gutter from the left rect before applying `frac = (want_left-64)/(pair-128)`. The
|
||||||
|
96 px logical floor applies to the plot area. Nothing else changes.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- `tests/test_figure.cpp`: with sharing on, a `{2}` row has equal y ranges after a `set_y_range` on
|
||||||
|
either cell; a second cell has zero y gutter and the first the common maximum; widths tile exactly and
|
||||||
|
equal weights give equal plot widths; group autoscale covers the union and ignores empty cells; sharing
|
||||||
|
off reproduces today's layout; `{1}` rows unaffected.
|
||||||
|
- Split probe gains: after a y gesture on one cell the other's range is identical; non-first cells have
|
||||||
|
no y gutter; fit-all covers both cells' traces; both rulers line up across cells; a divider drag still
|
||||||
|
tracks the pointer within 1 px.
|
||||||
|
- Regression: layout, cursors, measurements, readouts, E-1608 probes read as before; re-baseline only
|
||||||
|
what this feature explains. `gtk warnings: 0`, no `Gtk-CRITICAL`.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
`README.md`, `docs/USER-GUIDE.md`, `help/C/org.udpscope.app/` (layout page), `docs/claude/probe-readings.md`,
|
||||||
|
`docs/claude/invariants-plot-and-layout.md` (the "one gutter, not per column" rule and the per-cell y
|
||||||
|
rule), `docs/claude/build-and-run.md`, `ui_context_help.cpp`, `CLAUDE.md` if it mentions per-cell y.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# TCPStreamer source kind
|
||||||
|
|
||||||
|
A new source kind `tcps` ("TCPStreamer") that receives a MARTe2 `TCPStreamer` DataSource, either
|
||||||
|
standalone or through a shared `TCPStreamService`.
|
||||||
|
|
||||||
|
## Wire and scope
|
||||||
|
|
||||||
|
- Both transports are plain UDPS framing over TCP. A standalone `TCPStreamer` (TCPSServer) sends
|
||||||
|
CONFIG then DATA on connect. A `TCPStreamService` additionally requires a first client frame,
|
||||||
|
`SUBSCRIBE` (type 5, payload = stream name, 1..255 bytes).
|
||||||
|
- A plain server ignores `SUBSCRIBE`, so the client sends it only when a stream name is set.
|
||||||
|
- One connection carries one stream. Multiplexed lists (`"a,b,c"`, stream index in `fragmentIdx`)
|
||||||
|
are out of scope: a udpscope source is single-timebase. Several streams are merged server-side
|
||||||
|
with `TCPStreamJoin`, which publishes an ordinary named stream.
|
||||||
|
|
||||||
|
## `deps/udpsclient`: a TCP transport mode
|
||||||
|
|
||||||
|
- `udps_client_config_t` gains `use_tcp` and `stream_name`.
|
||||||
|
- New `connect_tcp()`: open the TCP socket, send `SUBSCRIBE` if a stream name is set, send no
|
||||||
|
`CONNECT`. No UDP socket, no keepalive ACK.
|
||||||
|
- `udps_client_poll` reuses `read_tcp_frame` / `handle_packet`.
|
||||||
|
- `reorder_window_s` is forced to 0 in TCP mode: TCP is ordered, so a counter gap is a server-side
|
||||||
|
drop and is reported at once.
|
||||||
|
- Silence timeout and reconnect delay behave as in UDP (dead server, or a stalled client the server
|
||||||
|
evicted, reconnects).
|
||||||
|
- The datagram tap does not fire; the packet capture stays UDP-only (`has_datagrams()` false).
|
||||||
|
- Additive. Recorded in `docs/claude/build-and-run.md` as a sixth intentional upstream change.
|
||||||
|
|
||||||
|
## Source adapter and kind
|
||||||
|
|
||||||
|
- `UdpsSource` takes a transport argument (`UdpsSource(cfg, Transport::Tcp)`) instead of being
|
||||||
|
copied: its callbacks are identical. The TCP variant reads `server`, `port` (default 44500) and
|
||||||
|
`stream`.
|
||||||
|
- `src/source/tcps_kind.cpp`: kind `tcps`, display name "TCPStreamer". Form rows: Server, Port,
|
||||||
|
Stream name (optional). Description: "Leave empty for a standalone TCPStreamer; name the stream
|
||||||
|
for a TCPStreamService".
|
||||||
|
- Identity: `host:port` or `host:port/stream`. Session naming follows the existing non-UDPS rule
|
||||||
|
(`tcps-<identity>`, port 1, `source_kind=tcps`, `source_opt_*`).
|
||||||
|
- Registered in `register_builtin_sources()` and `CMakeLists.txt`.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
No unit suite. Extend the self-check in `src/ui_probe.cpp` with an in-process fake TCP server that
|
||||||
|
sends CONFIG then DATA, and requires `SUBSCRIBE` in one mode. Probe lines assert the signals, the
|
||||||
|
frame count, and the reconnect after the server closes. Baselines: exactly one build warning, no
|
||||||
|
`Gtk-CRITICAL`. A smoke test against a real MARTe `TCPStreamer` if one is runnable.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
README and `docs/claude/architecture.md` (Sources) describe the kind; `docs/claude/probe-readings.md`
|
||||||
|
gets the new probe lines.
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="cursors">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#analysis"/>
|
||||||
|
<link type="seealso" xref="measurements"/>
|
||||||
|
<desc>The shared vertical cursor pair and the per-row horizontal rulers, their four modes,
|
||||||
|
and how a reading is formatted.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Cursors</title>
|
||||||
|
|
||||||
|
<p>Cursors read values off the traces. There are two kinds: one <em>shared vertical
|
||||||
|
pair</em> that every row carries at the same time, and <em>horizontal rulers</em>, one pair
|
||||||
|
per row, that belong to that row alone.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Press the cursors button in the header bar, or <key>c</key>. It switches the cursors on
|
||||||
|
and off.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The readings are in the dock (a read-only copy) and on the <gui>Cursors</gui> page, which
|
||||||
|
<gui>Edit</gui> beside <gui>Cursors</gui> opens over the plot and which owns the mode
|
||||||
|
and a <gui>Reset</gui> button. Drag a marker to move it.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The mode dropdown offers four modes: <gui>vertical</gui> (the shared time pair),
|
||||||
|
<gui>horizontal</gui> (the per-row rulers), <gui>both</gui>, and <gui>mix</gui>.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>The vertical pair is <em>one</em> time for the whole figure, so moving it on
|
||||||
|
any row moves it on all of them. That is what makes <code>dt</code> meaningful across
|
||||||
|
rows.</p></item>
|
||||||
|
<item><p>A ruler belongs to its row and nothing shares it, so two rows' rulers are
|
||||||
|
independent. The plots of a split row are one row here: they share their y axis, so the
|
||||||
|
rulers are at the same values in each.</p></item>
|
||||||
|
<item><p><gui>mix</gui> is the reading mode: one line per <em>bound</em> 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.</p></item>
|
||||||
|
<item><p>A value is the <em>nearest stored sample's</em>, 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.</p></item>
|
||||||
|
<item><p>Every reading carries a <em>metric prefix</em> so the digits sit in
|
||||||
|
<code>1..999</code>: <code>10.52 mV</code>, not <code>0.01 V</code>. Two decimals is all
|
||||||
|
an instrument landing on an <code>f32</code> sample can claim, so without the prefix a
|
||||||
|
small value would read <code>0.00</code> and the reading would be gone. A stream that
|
||||||
|
declares no unit gets the prefix anyway, written tight against the number
|
||||||
|
(<code>585.24m</code>) — the space separates the number from its unit, and a spaced
|
||||||
|
<code>m</code> with nothing after it would read as metres.</p></item>
|
||||||
|
<item><p>A marker past the newest sample reads <code>--</code>. With only one marker
|
||||||
|
over data the row reads <code>partial</code>: a difference between a number and nothing
|
||||||
|
is not a number.</p></item>
|
||||||
|
<item><p>The hover crosshair also writes its two coordinates where the eye already
|
||||||
|
looks: the value where its horizontal line meets the y axis, and the time where its
|
||||||
|
vertical line meets the x axis — each in a small box among that axis' own tick labels,
|
||||||
|
in the axis' own format. Each box is centred on the line it names, and its background
|
||||||
|
covers what is behind it, so a tick label underneath never prints through the number.
|
||||||
|
The preference's <gui>Crosshair axis labels</gui> turns the pair off.</p></item>
|
||||||
|
<item><p>In a split row the horizontal line runs across <em>every</em> plot of the row,
|
||||||
|
not just the one under the pointer: the plots share one y range, so it is one value
|
||||||
|
along the whole row. It stops at the row's edges — another row has a scale of its
|
||||||
|
own.</p></item>
|
||||||
|
<item><p>In <gui>mix</gui> 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 <em>on
|
||||||
|
that sample</em>, 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.</p></item>
|
||||||
|
<item><p>The time row exists only in the modes that show the vertical pair.
|
||||||
|
<gui>horizontal</gui> has no time row, by design.</p></item>
|
||||||
|
<item><p>A cursor <em>stays where you put it</em> while a live view rolls under it,
|
||||||
|
which is what a bench scope does: its <code>T0</code> is an age, so a marker left at
|
||||||
|
<code>-1.5s</code> 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.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>Three traces in <gui>mix</gui> mode with both markers over data: 3 lines and 6 crossing
|
||||||
|
marks, each line reading like <code>sine 0.31 V -0.84 V -1.15 V</code>. Move the second
|
||||||
|
marker past the newest sample and the third column reads <code>--</code>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="export">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#output"/>
|
||||||
|
<link type="seealso" xref="sessions"/>
|
||||||
|
<desc>Writing the visible range of every plotted trace to NumPy, CSV or MDF 3.30, and what
|
||||||
|
each format carries.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Saving and export</title>
|
||||||
|
|
||||||
|
<p>Export writes the <em>visible range</em> of every plotted trace to a file: NumPy
|
||||||
|
<file>.npy</file>, <file>.csv</file>, or <gui>MDF 3.30</gui> (<file>.mf3</file> or
|
||||||
|
<file>.mdf</file>), chosen by the extension you type.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Pick <gui>Export visible range…</gui> in the overflow menu, or press
|
||||||
|
<keyseq><key>Ctrl</key><key>E</key></keyseq>.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>A file chooser asks where; the format follows the path's extension.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>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.</p></item>
|
||||||
|
<item><p>The rows are the <gui>stored samples</gui>, 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.</p></item>
|
||||||
|
<item><p>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 <em>that</em> —
|
||||||
|
read the status bar's ⓘ popover for the figure before trusting a sample count.</p></item>
|
||||||
|
<item><p>Two time columns. <code>time</code> 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. <code>abs_seconds</code> 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.</p></item>
|
||||||
|
<item><p>An export taken <em>mid-capture</em> 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.</p></item>
|
||||||
|
<item><p><gui>MDF</gui> exists for handing a window to another tool, and its shape follows
|
||||||
|
the contract that reader uses: one channel group, every channel record-interleaved, the
|
||||||
|
master channel as the x axis and the unit in the channel metadata. So there the
|
||||||
|
<em>master is <code>abs_seconds</code></em>, named <code>time</code> — the clock that is
|
||||||
|
the same in every display mode — and the display axis goes out beside it as an ordinary
|
||||||
|
channel, <code>time_relative</code>. Both are seconds; each signal carries the source's
|
||||||
|
own unit. That is also what <gui>ScryLab</gui> reads: channel name → signal name, master
|
||||||
|
→ x axis, unit → the signal's unit.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
|
||||||
|
(<code>time</code>, <code>abs_seconds</code>, three signals). The <file>.npy</file> is
|
||||||
|
<code>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</code> bytes. Read it back with
|
||||||
|
<code>pd.DataFrame(np.load(path))</code>. The <file>.mf3</file> is bigger — a block per
|
||||||
|
channel plus a comment — <code>272 + comment + rows·record + 28 + channels·274 + 30</code>
|
||||||
|
bytes, with <code>record = 16 + 4·cols</code> and <code>channels = cols + 2</code> for the
|
||||||
|
master and the relative axis. Read that one back with <code>asammdf.MDF(path)</code> or in
|
||||||
|
ScryLab.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 129 KiB |
@@ -0,0 +1,67 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="guide" style="task"
|
||||||
|
id="index">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<desc>udpscope plots a live UDPStreamer source as one figure of up to eight stacked
|
||||||
|
plot rows sharing a single time axis, with a trigger, cursors, measurements, math
|
||||||
|
channels and an export. This manual covers connecting, the window and its panels,
|
||||||
|
navigating and pausing the plot, the trigger and its event cache, analysis, and
|
||||||
|
saving and exporting.</desc>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>udpscope</title>
|
||||||
|
|
||||||
|
<section id="main">
|
||||||
|
<title>Getting started</title>
|
||||||
|
<list>
|
||||||
|
<item><p><link xref="overview">Overview</link> — what udpscope is, how it reaches a
|
||||||
|
source, the window and its panels, and a first five minutes.</p></item>
|
||||||
|
<item><p><link xref="source">Choosing a source</link> — the command line, the
|
||||||
|
chooser, and opening an exported waveform instead of a live stream.</p></item>
|
||||||
|
<item><p><link xref="signals">Signals and calibration</link> — how a signal becomes
|
||||||
|
a channel, what starts acquisition, and how a scale and offset are applied.</p></item>
|
||||||
|
<item><p><link xref="layout">Rows, layout and drag & drop</link> — up to eight
|
||||||
|
rows, four traces each, and the layout editor.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="viewing">
|
||||||
|
<title>Viewing</title>
|
||||||
|
<list>
|
||||||
|
<item><p><link xref="window">Time window</link> — the one control that changes the
|
||||||
|
window, and the decimation a long window causes.</p></item>
|
||||||
|
<item><p><link xref="navigate">Navigating the plot</link> — panning, zooming and
|
||||||
|
fitting with the mouse and the keyboard.</p></item>
|
||||||
|
<item><p><link xref="pause">Pause and play</link> — freezing the display without
|
||||||
|
stopping the stream, and how long you have been looking.</p></item>
|
||||||
|
<item><p><link xref="trigger">Trigger</link> — the four trigger shapes, arming,
|
||||||
|
hold-off, the event cache, and searching the buffer.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="analysis">
|
||||||
|
<title>Analysis</title>
|
||||||
|
<list>
|
||||||
|
<item><p><link xref="cursors">Cursors</link> — the shared vertical pair and the
|
||||||
|
per-row horizontal rulers.</p></item>
|
||||||
|
<item><p><link xref="measurements">Measurements</link> — a statistic of the visible
|
||||||
|
window, its on-plot marks, and its statistics line.</p></item>
|
||||||
|
<item><p><link xref="math">Math channels</link> — building a derived channel from a
|
||||||
|
graph of source nodes, operations and one output.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="output">
|
||||||
|
<title>Output and state</title>
|
||||||
|
<list>
|
||||||
|
<item><p><link xref="export">Saving and export</link> — the visible range to
|
||||||
|
<file>.npy</file>, <file>.csv</file> or <file>.mf3</file>.</p></item>
|
||||||
|
<item><p><link xref="sessions">Sessions and settings</link> — the per-source
|
||||||
|
configuration files and what they carry.</p></item>
|
||||||
|
<item><p><link xref="status">Status bar and diagnostics</link> — the glance line, the
|
||||||
|
ⓘ popover, and the activity badge.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="layout">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#main"/>
|
||||||
|
<link type="seealso" xref="signals"/>
|
||||||
|
<desc>How the figure's rows, plots and traces are arranged, how to drop a signal onto a plot, and
|
||||||
|
how the layout editor adds and removes rows and splits a row into plots side by side.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Rows, layout and drag & drop</title>
|
||||||
|
|
||||||
|
<p>The figure is a stack of rows holding 1 to 8 plots in all, and a row can be split into up
|
||||||
|
to 4 plots side by side. Each plot holds up to 4 traces. Every plot shares one time axis and
|
||||||
|
the figure has one pair of side margins, so the y labels line up, the data areas start and
|
||||||
|
end on the same two columns, and the 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.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Drag a signal's badge from the badge bar onto a row to
|
||||||
|
add a trace.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Press <key>l</key>, pick <gui>Layout editor</gui> from the overflow menu, or pick
|
||||||
|
<gui>Edit the layout…</gui> from a plot's right-click menu, to add
|
||||||
|
and remove rows: a <gui>×</gui> appears at the top left of each plot and a <gui>+</gui>
|
||||||
|
on each boundary between rows, including above the first and below the last.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The editor's own button in the top-right corner leaves it.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="split">
|
||||||
|
<title>Splitting a row</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>In the layout editor, click the <gui>+</gui> at a row's left or right edge, or the
|
||||||
|
<gui>+</gui> between two plots, to add a plot to that row. The new plot starts empty.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Drag the double-headed arrow on the boundary between two plots to resize them. Neither plot can be
|
||||||
|
made narrower than about 96 px, and the widths are saved with the session.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Click the <gui>×</gui> of a plot in a split row to remove that plot and its traces.
|
||||||
|
In a row with a single plot the <gui>×</gui> removes the row instead.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
<p>All the plots of a row, and of the whole figure, share one time window: zooming or
|
||||||
|
panning along time in any of them moves all of them, and the vertical cursors are drawn in
|
||||||
|
every plot. The plots of a split row also share one y range: the y labels are drawn at the
|
||||||
|
left of the first plot only, the horizontal cursors sit at the same values in every plot, the
|
||||||
|
hover crosshair's horizontal line spans all of them, and a
|
||||||
|
zoom, pan, fit or fixed range in any of them applies to the whole row. The time labels
|
||||||
|
sit under the bottom row, and under any row above it whose columns differ from the row below. A row holds at most 4 plots and the figure at most 8
|
||||||
|
in total; the <gui>+</gui> buttons go grey at the limit.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="fullscreen">
|
||||||
|
<title>A row full screen, and a second window</title>
|
||||||
|
<list>
|
||||||
|
<item><p>Every row carries a <gui>full-screen</gui> button in its top-right corner: it comes
|
||||||
|
out of hiding when the pointer moves near that corner and hides again when it leaves, so the
|
||||||
|
plot is not decorated by a control you are not reaching for. One click makes that row fill the plot area — the
|
||||||
|
other rows step aside with everything they were plotting — and the same button (now the
|
||||||
|
restore icon) brings the whole layout back exactly as it was. It is a view, not an edit:
|
||||||
|
nothing is saved, nothing is dropped, and the rows' traces, colours and cursors come back
|
||||||
|
where they were. The plot's right-click menu offers the same as <gui>Full screen this
|
||||||
|
figure</gui> (and <gui>Restore the layout</gui> while one is enlarged). A layout of a single
|
||||||
|
figure has nothing to enlarge *to*, so it carries no button.</p></item> Beside the toggle, shown the same way, a <gui>detach</gui> button moves the figure into a
|
||||||
|
window of its own — it leaves the main window's rows; the detached window carries an
|
||||||
|
<gui>attach</gui> button in the same corner, which returns the figure to the main window and
|
||||||
|
closes the window.
|
||||||
|
<item><p>A plot's right-click menu has <gui>Open this plot in a new window</gui>: a second
|
||||||
|
window showing the same stream through a copy of that figure (its cells, their widths and
|
||||||
|
every trace), with its own axes following the main window's time window. The overflow menu's
|
||||||
|
<gui>New scope window</gui> opens the same kind of window with an empty figure. A signal
|
||||||
|
badge dragged onto a window's plot lands in that window (on the plot under the pointer), and
|
||||||
|
the signal page's <gui>Plot on</gui> section lists every window — one named group per
|
||||||
|
window, one button per plot of it. Closing one closes that view only; the main window keeps
|
||||||
|
acquiring.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>A plot's own <gui>right-click menu</gui> (the same compact rows as the app's
|
||||||
|
menu) holds the y-axis scale (for the whole row), the two fits, and <gui>Remove signal</gui>, which swaps
|
||||||
|
the menu to that plot's traces plus <gui>All traces on this plot</gui> — with a
|
||||||
|
<gui>Back</gui> row to return. That is how a trace comes off a row without going through
|
||||||
|
the layout editor.</p></item>
|
||||||
|
<item><p>A drop <em>adds</em> a trace to the row's next free slot; it does not replace
|
||||||
|
one. With four traces already on a plot the drop is refused.</p></item>
|
||||||
|
<item><p>Inserting a row moves the rows below it <em>with their contents</em>, split
|
||||||
|
rows included. 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 split row's plots are named
|
||||||
|
<em>2a</em>, <em>2b</em> and so on.</p></item>
|
||||||
|
<item><p>A trace is remembered by the signal's <em>name</em>, not by its channel number,
|
||||||
|
because a new CONFIG or an enable renumbers the channels and an index would silently
|
||||||
|
re-point the trace.</p></item>
|
||||||
|
<item><p>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.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>With three traces on row 1, pressing <key>l</key> shows 7 buttons: the <gui>×</gui>,
|
||||||
|
the two <gui>+</gui> above and below the row with their two resize arrows, and the two
|
||||||
|
<gui>+</gui> at the row's left and right edges. Clicking the <gui>+</gui> above row 1 gives 2 rows and 12 buttons,
|
||||||
|
with the three traces now on row 2 — the layout probe reports exactly this as
|
||||||
|
<code>rows=2 buttons=12 traces=[0:-|1:sine,ramp,noise]</code>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="math">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#analysis"/>
|
||||||
|
<link type="seealso" xref="signals"/>
|
||||||
|
<desc>Building a derived channel from a graph of source nodes, operations and one output,
|
||||||
|
and how the graph is evaluated.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Math channels</title>
|
||||||
|
|
||||||
|
<p>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.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Open the <gui>Signals</gui> page (the <gui>+</gui> badge, or <key>g</key>). Math channels
|
||||||
|
are listed under a <gui>MATH</gui> heading.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The heading's <gui>+</gui> adds a channel. <gui>Edit graph</gui>, on a channel's page,
|
||||||
|
shows the graph editor:
|
||||||
|
nodes as boxes, click a box to select it and edit its parameters beside the canvas.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Inside the editor, <gui>add node</gui> is a menu button whose popover picks the op,
|
||||||
|
so the node arrives as the operation you asked for.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="gestures">
|
||||||
|
<title>Graph gestures</title>
|
||||||
|
<p><gui>drag</gui> 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 <key>Delete</key> to remove
|
||||||
|
it; drag a box to move it. <gui>Wheel</gui> zooms about the pointer. <gui>Drag the
|
||||||
|
background</gui> to pan. <key>f</key> fits all nodes into view;
|
||||||
|
<keyseq><key>Ctrl</key><key>0</key></keyseq> resets the view. <gui>tidy</gui>, the button
|
||||||
|
beside <gui>fit</gui> in the editor, lays the graph out in topological columns and frames
|
||||||
|
it. The orange dot marks the output node; click <gui>set as output</gui> to move it.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>The graph is evaluated as an ordered list, so a node may only read ones
|
||||||
|
<em>above</em> 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.</p></item>
|
||||||
|
<item><p>A derived channel <em>is</em> a channel. It is listed with the signals, gets a badge once computed, can be
|
||||||
|
plotted, measured, exported, and used as a source for another math channel.</p></item>
|
||||||
|
<item><p>The math runs on the <em>stored</em> 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.</p></item>
|
||||||
|
<item><p>A source may only name an <em>acquired</em> 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.</p></item>
|
||||||
|
<item><p>The output defaults to the <em>last</em> node, and a node added to a linear
|
||||||
|
graph becomes the output. Choosing an earlier node explicitly is respected.</p></item>
|
||||||
|
<item><p>Every numeric field starts at its own identity value — gain 1, offset 0 — not
|
||||||
|
at the bottom of its range.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="why">
|
||||||
|
<title>Why an operation sometimes does nothing</title>
|
||||||
|
<p>A graph's <em>output</em> 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. <gui>Set as output</gui> moves the dot.</p>
|
||||||
|
|
||||||
|
<p><gui>Apply</gui> is insensitive while the graph is incomplete. A line beneath the form
|
||||||
|
says why: an input that is not connected, no output chosen, or a <em>channel</em> 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.</p>
|
||||||
|
|
||||||
|
<p>The filters are real filters. 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 <em>stored</em> 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.</p>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<title>The math channel editor</title>
|
||||||
|
<desc>Nodes as boxes on a canvas, the orange dot marking the output, and the selected
|
||||||
|
node's parameters beside it.</desc>
|
||||||
|
<media type="image" mime="image/png" src="figures/math-editor.png" width="938" height="420">
|
||||||
|
<p>A sine source wired through a gain into a filter, with the output on the last
|
||||||
|
node.</p>
|
||||||
|
</media>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p><code>[sine] → [gain x2]</code> gives a channel reading <code>y=[-2, 2]</code>. 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.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="measurements">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#analysis"/>
|
||||||
|
<link type="seealso" xref="cursors"/>
|
||||||
|
<desc>A measurement is a statistic of one signal over the window the pane is about — the
|
||||||
|
visible range, or the frozen frame while a view is pinned — with optional
|
||||||
|
on-plot marks and a per-measurement statistics line.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Measurements</title>
|
||||||
|
|
||||||
|
<p>A measurement is a statistic of one signal over the window the pane is about, recomputed as
|
||||||
|
that window moves. The pane lists them with their values; each can also draw its result on
|
||||||
|
the plot.</p>
|
||||||
|
|
||||||
|
<p>That window is the <em>visible range</em> — so on a live view the numbers follow what you
|
||||||
|
pan and zoom to. On a <em>still frame</em> — a held event, a paused stream, an opened file — it
|
||||||
|
is the frame's own extent instead, so panning or zooming inside it changes nothing: the data
|
||||||
|
being measured has not changed and you are only looking closer.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Press <key>m</key>, or <gui>Edit</gui> beside <gui>Measurements</gui> in the dock. Either opens the
|
||||||
|
<gui>Measurements</gui> page over the plot; the dock itself shows each measurement's value,
|
||||||
|
and a click on one opens its dialog.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The page's <gui>+</gui> adds one; clicking a row opens the dialog where the signal,
|
||||||
|
the type, the threshold share or shares and the on-plot marks are set, with
|
||||||
|
<gui>Save</gui> and <gui>Delete</gui>.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>The types are average, top, bottom, min, max, peak to peak, amplitude, above,
|
||||||
|
below, between, pulse duration, period and frequency. <gui>top</gui> and <gui>bottom</gui> are the waveform's
|
||||||
|
<em>flat</em> high and low levels: the level the signal dwells at, taken from the densest
|
||||||
|
band of the window on each side of the split. A band-limited edge overshoots and rings —
|
||||||
|
a nominal 2 V square reaches about ±1.18 at its edges — and a ripple or an overshoot is
|
||||||
|
therefore <em>not</em> what these read. <gui>amplitude</gui> is the delta between those two
|
||||||
|
levels, and <gui>peak to peak</gui> is the swing between the extremes (so on a rippled
|
||||||
|
square <gui>amplitude</gui> reads 2 V where <gui>peak to peak</gui> reads 2.36 V).</p></item>
|
||||||
|
<item><p><gui>above</gui>, <gui>below</gui> and <gui>between</gui> are the opposite kind of
|
||||||
|
number: the plain <em>mean of the samples</em> above a share, below it, or between two
|
||||||
|
shares. That is a different question from <gui>top</gui>/<gui>bottom</gui> even about the
|
||||||
|
same split, and it is the older one — on a unit sine <gui>above 50%</gui> reads 0.64 V
|
||||||
|
(2/π, the mean of a half cycle) where <gui>top</gui> reads 0.96 V (near the peak, which is
|
||||||
|
where a sine dwells most), and on a band-limited square <gui>above 50%</gui> reads 0.99 V
|
||||||
|
where <gui>top</gui> reads the plateau, 1.00 V: the edge's ring is inside the first and not
|
||||||
|
inside the second. <gui>between</gui> takes <em>two</em> shares — <gui>from %</gui> and
|
||||||
|
<gui>to %</gui> — and reports the mean of the samples inside that band, which no single
|
||||||
|
split can answer; the order you type them in does not matter, since a band between 80 % and
|
||||||
|
20 % is the band between 20 % and 80 %.</p></item>
|
||||||
|
<item><p>A mean over a side carries whatever is on that side, so these three are the right
|
||||||
|
numbers when you want the <em>energy</em> or the <em>offset</em> of part of the waveform,
|
||||||
|
and the wrong ones when you want the level a plateau sits at. Like every other measurement,
|
||||||
|
one with nothing to measure reads <code>--</code> rather than a made-up number: a share at
|
||||||
|
100 % has no sample above it, and a band can hold none at all — on an ideal
|
||||||
|
(non-ringing) square every sample is exactly ±1, so a band between 50 % and 75 % is
|
||||||
|
empty.</p></item>
|
||||||
|
<item><p><gui>top</gui> and <gui>bottom</gui> also read <em>near the peak</em> on a
|
||||||
|
waveform that has no flat part, which is the honest reading rather than a failure: a unit
|
||||||
|
sine has no plateau, so the densest band is at its peak and these read about ±0.96 — not
|
||||||
|
the 0.64 a mean over each half would give, and not ±1 either. Only a level the signal
|
||||||
|
actually dwells at is a level.</p></item>
|
||||||
|
<item><p>The <em>split</em> says which side a level is read from, so any share between the
|
||||||
|
two levels reads the same number; it is not a boundary the value depends on. The one case
|
||||||
|
where it is read directly is a window the scope cannot summarize by level — a window
|
||||||
|
shorter than a level's own resolution, or one read through <gui>hf</gui> — where
|
||||||
|
<gui>top</gui>/<gui>bottom</gui> fall back to the mean of each side of the split and
|
||||||
|
<gui>amplitude</gui> to the window's peak to peak.</p></item>
|
||||||
|
<item><p>Each row names the <em>channel</em> it measures: its number among the active
|
||||||
|
channels (1., 2., … the same number the signal's badge in the bar carries), its name, and
|
||||||
|
the type — <code>1. sine · average</code>. The measurement dialog's signal dropdown
|
||||||
|
numbers its entries the same way. A channel list that changes (a signal switched off, a
|
||||||
|
new one enabled) renumbers, because the number is the answer to "which active channel is
|
||||||
|
this".</p></item>
|
||||||
|
<item><p>The on-plot marks come in two kinds and the mix is the reading: a <em>level</em>
|
||||||
|
mark is a ruler at a value — its value written in a small box on the ruler, the way the
|
||||||
|
axis writes its own ticks — and a <em>time</em> mark is a marker at a position, with the
|
||||||
|
position's own badge beside it. A level ruler's badge carries its number; a time
|
||||||
|
marker's carries its time. Marks whose badges meet share one panel with one line each,
|
||||||
|
so a column of close readings is one boxed readout rather than a smear of texts; and a
|
||||||
|
box over a noisy trace is what keeps the number readable. An average
|
||||||
|
draws one level mark; a max draws a level mark <em>and</em> a time mark at the first
|
||||||
|
sample that reached it; <gui>peak to peak</gui> draws a ruler at each extreme and
|
||||||
|
<gui>amplitude</gui> a ruler at each of its two flat levels — the pair is the span, so
|
||||||
|
the two rulers <em>are</em> the measurement.</p></item>
|
||||||
|
<item><p>Marks cannot be dragged. A measurement mark says what the measurement measured,
|
||||||
|
so it is created unselectable — as is the trigger marker.</p></item>
|
||||||
|
<item><p>A pass is throttled to a cadence rather than run every frame, so a ruler over a
|
||||||
|
live view is <em>stepped</em>. The number and the ruler come from the same pass, so they
|
||||||
|
always agree.</p></item>
|
||||||
|
<item><p>An invalid pass keeps its <em>level</em> marks and drops its <em>time</em>
|
||||||
|
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.</p></item>
|
||||||
|
<item><p>A crossing is counted with <em>hysteresis</em>, 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.</p></item>
|
||||||
|
<item><p>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.</p></item>
|
||||||
|
<item><p>A window with too few crossings reads <code>invalid</code> / <code>--</code>
|
||||||
|
rather than a plausible invented number. Frequency over a window holding one edge is the
|
||||||
|
usual case.</p></item>
|
||||||
|
<item><p><gui>frequency</gui> is reported in Hz and <gui>period</gui>/<gui>pulse
|
||||||
|
duration</gui> in seconds; every other type carries the channel's own unit. A value is
|
||||||
|
written with a <em>metric prefix</em> so its digits sit in <code>1..999</code> —
|
||||||
|
<code>636.62 mV</code>, not <code>0.64 V</code> — 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 (<code>636.62m</code>), since two decimals of an unprefixed small
|
||||||
|
value is <code>0.00</code>.</p></item>
|
||||||
|
<item><p>Cost is bounded by reading the summary pyramid the producer already maintains,
|
||||||
|
not the raw window: a bucket's sum is a sum, so the mean, the extremes and a level's own
|
||||||
|
mean are exact. <gui>top</gui>, <gui>bottom</gui> and <gui>amplitude</gui> read the
|
||||||
|
<em>density</em> of the window out of that same summary — which is what tells a flat level
|
||||||
|
from a ripple — so those three cost a second read of the summary and never of a sample, and
|
||||||
|
<gui>between</gui>'s second share is one more level in the same summary walk rather than a
|
||||||
|
walk of its own.</p></item>
|
||||||
|
<item><p><gui>Statistics</gui> adds a second line to every measurement showing
|
||||||
|
<code>n</code>, the mean, the standard deviation and the range. Each observation is one
|
||||||
|
<em>recompute</em> of the pane, so <code>n</code> counts windows measured, not samples —
|
||||||
|
a mean frequency over many windows is what that claims to be. <gui>Reset</gui> clears
|
||||||
|
every accumulator and starts fresh. A change of display window, a CONFIG or a
|
||||||
|
measurement edit also resets; a pause, a resume, a zoom, a pan and the toggle itself do
|
||||||
|
not, so turning the line off to read the plot and back on again keeps the history. On a
|
||||||
|
still frame a pan or a zoom changes nothing for the history to record in the first place —
|
||||||
|
the values do not move there at all.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="gate">
|
||||||
|
<title>The gate</title>
|
||||||
|
<p>A measurement's dialog carries <gui>Gate enabled</gui>. Every measurement
|
||||||
|
with it set reads only the samples inside one shared band — violet, labelled <em>gate</em>,
|
||||||
|
carried by its own two draggable edges on the plots, which are not the cursors (those keep
|
||||||
|
meaning what the cursors pane reads). Measurements without it are untouched: they read the
|
||||||
|
whole window and share the walks they always shared.</p>
|
||||||
|
<p>The band is shown exactly while some measurement is gated — gate one and it appears,
|
||||||
|
gate or delete the last one and it goes — because it is one area that all of the gated
|
||||||
|
measurements share, not a property of any one of them. Drag an edge to resize it, grab the
|
||||||
|
middle to move it.</p>
|
||||||
|
<p>The band lives in the same relative time the axis labels use, so it stays where it was
|
||||||
|
put in both modes: live, a band at 300 ms to 100 ms ago stays put on screen while the data
|
||||||
|
streams beneath it — the gated read of the newest data a bench scope gives — and on a held
|
||||||
|
event it names a fixed slice of the frozen frame. A band with no samples in it — dragged
|
||||||
|
outside the view, or over a gap — reads <code>--</code> like any empty window. The band's
|
||||||
|
position and each measurement's gate flag are saved with the session.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>On a 1 Hz ±1 sine: <code>average=0</code>, <code>top=0.957988</code>,
|
||||||
|
<code>bottom=-0.957988</code> (the flat levels, near the peaks — a sine has no plateau),
|
||||||
|
<code>max=1</code>, <code>min=-1</code>,
|
||||||
|
<code>peak to peak=2</code>, <code>amplitude=1.915983</code>, <code>pulse duration=0.5 s</code>
|
||||||
|
(duty 0.5), <code>period=1 s</code>, <code>frequency=1 Hz</code>. On the same stream's
|
||||||
|
band-limited <code>square</code>, whose edges ring out to ±1.179: <code>top=0.999983</code>,
|
||||||
|
<code>bottom=-0.999983</code>, <code>amplitude=1.999966</code> against
|
||||||
|
<code>peak to peak=2.358701</code> — the levels are the plateau's and the extremes are the
|
||||||
|
edges'. Over a 2 M-sample window those passes read about 0.1% of the samples.</p>
|
||||||
|
<p>The mean-over-a-side types on the same two windows: on the sine
|
||||||
|
<code>above 50=0.636620</code>, <code>below 50=-0.636620</code> and
|
||||||
|
<code>between 50 75=0.255873</code>; on the rippled square <code>above 50=0.986496</code> and
|
||||||
|
<code>between 50 75=0.302744</code>, against <code>1.000000</code> for the same
|
||||||
|
<code>above 50</code> on an ideal (non-ringing) square. A <code>ramp</code>, whose density is
|
||||||
|
uniform, is the shape that names what a band mean <em>is</em>: <code>above 50=0.500000</code>
|
||||||
|
and <code>between 50 75=0.250000</code>, each band's own centre.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="navigate">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#viewing"/>
|
||||||
|
<link type="seealso" xref="window"/>
|
||||||
|
<desc>Panning, zooming and fitting the plot with the mouse and the keyboard, and what a
|
||||||
|
pinned view refuses to do.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Navigating the plot</title>
|
||||||
|
|
||||||
|
<p>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.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<terms>
|
||||||
|
<item>
|
||||||
|
<title>Wheel</title>
|
||||||
|
<p><gui>Wheel</gui> zooms both axes · <gui>Shift</gui>+<gui>Wheel</gui> zooms x only ·
|
||||||
|
<gui>Ctrl</gui>+<gui>Wheel</gui> zooms y only.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Drag</title>
|
||||||
|
<p><gui>Drag</gui> pans · <gui>Shift</gui>+<gui>Drag</gui> is a box zoom on both axes ·
|
||||||
|
<gui>Ctrl</gui>+<gui>Drag</gui> on x only · <gui>Alt</gui>+<gui>Drag</gui> on values
|
||||||
|
only. The area being chosen is framed while you drag it, so you can see what the
|
||||||
|
release will apply.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Keyboard</title>
|
||||||
|
<p><key>+</key> and <key>-</key> zoom both axes; <key>Ctrl</key> with them restricts
|
||||||
|
to x, <key>Alt</key> to y. <gui>Arrow keys</gui> pan, <key>f</key> fits,
|
||||||
|
<key>Escape</key> cancels a gesture in progress.</p>
|
||||||
|
</item>
|
||||||
|
</terms>
|
||||||
|
|
||||||
|
<p>The full table is in the shortcut overview (<keyseq><key>Ctrl</key><key>/</key></keyseq>).</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>The keyboard zoom's modifiers are <em>not</em> the wheel's, and that is
|
||||||
|
deliberate: on common layouts <key>+</key> <em>is</em> <keyseq><key>Shift</key><key>=</key></keyseq>,
|
||||||
|
so a bare zoom-in already arrives with Shift set and Shift could not mean anything.
|
||||||
|
Ctrl/Alt mirror the box-zoom drags instead.</p></item>
|
||||||
|
<item><p>A <em>pinned</em> 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 <em>in</em>
|
||||||
|
works normally — "not outside the event" is not "not readable" — but a pan or a zoom out
|
||||||
|
is clamped to the data.</p></item>
|
||||||
|
<item><p>Nothing you do on the plot changes the <em>time window</em> — only the header
|
||||||
|
control does. A zoom or a pan moves the view inside the window; <key>f</key> (and
|
||||||
|
<gui>Fit X axis</gui>) is the <em>undo</em> 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.</p></item>
|
||||||
|
<item><p>The axis does not scroll — the <em>data</em> does. A live axis is a fixed
|
||||||
|
<code>[-window .. 0]</code> 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.</p></item>
|
||||||
|
<item><p>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.</p></item>
|
||||||
|
<item><p>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.</p></item>
|
||||||
|
<item><p>A trace holding far more samples than the plot has pixels is drawn <em>one
|
||||||
|
pixel column at a time</em>: 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 <em>crawl</em> 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.</p></item>
|
||||||
|
<item><p>Those columns are <em>graded by brightness</em>, 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.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>Pause a live view, then press <keyseq><key>Ctrl</key><key>+</key></keyseq>: x narrows
|
||||||
|
and y does not move. Press <keyseq><key>Alt</key><key>+</key></keyseq>: y narrows and x
|
||||||
|
does not. Eight doublings into a held 2 s capture, the trace still reaches both margins —
|
||||||
|
the zoom probe reports <code>gap_px</code> and <code>end_px</code> within a pixel or two
|
||||||
|
at every step.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="overview">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#main"/>
|
||||||
|
<desc>What udpscope plots, the parts of the window, and one pass through the
|
||||||
|
application from connecting to saving.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Overview</title>
|
||||||
|
|
||||||
|
<section id="what">
|
||||||
|
<title>What udpscope is</title>
|
||||||
|
<p>udpscope plots a live UDPStreamer source. One window holds up to eight rows of plots,
|
||||||
|
each with up to four 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
|
||||||
|
<cmd>--buffer-samples</cmd>; the drawing thread reads whatever that ring holds when a frame
|
||||||
|
starts, which is why a trace never makes the window stutter.</p>
|
||||||
|
|
||||||
|
<p>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.</p>
|
||||||
|
|
||||||
|
<p>The time axis is <em>relative to now</em>. While streaming it is a fixed
|
||||||
|
<code>[-window .. 0]</code> with the newest sample at 0, so x reads as an age:
|
||||||
|
<code>-1.5s</code> 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.</p>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<title>The udpscope window</title>
|
||||||
|
<desc>The header bar above the plots, the badge bar and the status bar under them, and
|
||||||
|
the dock on the right.</desc>
|
||||||
|
<media type="image" mime="image/png" src="figures/main-window.png" width="1280" height="800">
|
||||||
|
<p>One figure of three rows with three signals plotted, a badge for each in the badge
|
||||||
|
bar, the dock with its readouts and its events block, and the status bar reading
|
||||||
|
<gui>live</gui>.</p>
|
||||||
|
</media>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<p>Start the app with a source
|
||||||
|
(<cmd>udpscope --server 127.0.0.1 --port 8000</cmd>), or start it with no
|
||||||
|
<cmd>--server</cmd> 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 <cmd>--multicast</cmd> with the group and <cmd>--interface</cmd> with the local
|
||||||
|
address.</p>
|
||||||
|
|
||||||
|
<p>The signals arrive <em>disabled</em>. 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
|
||||||
|
empty badge bar is working correctly, not failing to connect. For the same reason a start
|
||||||
|
that has enabled nothing prints no <code>buffer allocated:</code> line on stderr.</p>
|
||||||
|
|
||||||
|
<note style="warning">
|
||||||
|
<p>Over SSH with X11 forwarding the window may never appear, and the cure is
|
||||||
|
<cmd>udpscope --renderer cairo</cmd>. Two symptoms tell the two halves apart: a
|
||||||
|
<code>vkCreateSwapchainKHR() ... VK_ERROR_INITIALIZATION_FAILED</code> warning is
|
||||||
|
GTK's default Vulkan renderer failing to make a swapchain and is what the option's
|
||||||
|
<code>GSK_RENDERER=cairo</code> answers; a window whose allocation stays zero
|
||||||
|
(<code>window_button_w=0</code>) means the plot's GL area has no context either, and
|
||||||
|
that is what its <code>LIBGL_ALWAYS_SOFTWARE=1</code> answers. Both variables are
|
||||||
|
exported before GTK initialises, because they are read when the renderer and the GL
|
||||||
|
context are created. <cmd>--renderer gl</cmd> is faster when indirect GL works.</p>
|
||||||
|
</note>
|
||||||
|
|
||||||
|
<note style="tip">
|
||||||
|
<p>See <link xref="source">Choosing a source</link> for the chooser and the multicast
|
||||||
|
options in full.</p>
|
||||||
|
</note>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="window">
|
||||||
|
<title>The window</title>
|
||||||
|
<terms>
|
||||||
|
<item>
|
||||||
|
<title>The header bar</title>
|
||||||
|
<p>Left to right: the time window (<gui>2 s</gui>), the trigger and the pause; then, on
|
||||||
|
the right, the cursors switch, the dock switch and the menu, which holds
|
||||||
|
<gui>Fit to data</gui>, the <gui>Layout editor</gui>, <gui>Export visible range</gui>,
|
||||||
|
<gui>Configurations</gui>, <gui>Help</gui>, <gui>Preferences</gui> and
|
||||||
|
<gui>About</gui>. The trigger button turns trigger mode on and off; the trigger's
|
||||||
|
settings are in the panel its badge opens.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>The plot</title>
|
||||||
|
<p>Up to eight rows, each with its own y axis and up to four traces, all on one shared
|
||||||
|
time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>The badge bar</title>
|
||||||
|
<p>The strip under the plot. On the left, one badge for each signal you have switched
|
||||||
|
on, then a <gui>+</gui>; past six signals they fold into a single <gui>N signals</gui>
|
||||||
|
badge. On the right, <gui>Trigger</gui>, which names the trigger's state, and <gui>Source</gui>, which is there only when the
|
||||||
|
source has controls of its own. A badge opens its page in the panel described next;
|
||||||
|
a signal's badge is also what you drag onto a row.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>The side panel over the plot</title>
|
||||||
|
<p>One panel, 360 px wide, slides over the plot's right edge and shows one page at a
|
||||||
|
time: the trigger, a signal's colour, calibration and rows, the list of signals to
|
||||||
|
add, the cursors, the measurements, the events, or a source's own controls. It closes
|
||||||
|
with a second click on the badge that opened it, with <key>Esc</key>, with
|
||||||
|
<gui>Close</gui>, or with a click on the plot, and it never resizes the plot.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>The dock</title>
|
||||||
|
<p>The column on the right, which the dock button in the header bar (<key>s</key>)
|
||||||
|
shows and hides. Its <gui>Readouts</gui> are a read-only copy of the cursors and of
|
||||||
|
every measurement; <gui>Edit</gui> beside each heading opens that instrument's full
|
||||||
|
page. Under them, <gui>Events</gui> lists the captured events, newest first; a click
|
||||||
|
replays one and <gui>Manage</gui> opens the full list. While the dock is hidden, or the window is narrow and the
|
||||||
|
dock slides over the plot, the readouts show in a strip under the plot instead, as long as the cursors are on or there is a measurement.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>The status bar</title>
|
||||||
|
<p>The last line, under the badge bar — 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 below is what a capture looks like: the trigger marker at the edge
|
||||||
|
and the axis pinned to the event.</p>
|
||||||
|
</item>
|
||||||
|
</terms>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<title>A held capture</title>
|
||||||
|
<desc>The trigger has latched, the state reads <gui>event held</gui>, and the axis is
|
||||||
|
pinned to the event rather than to the live edge.</desc>
|
||||||
|
<media type="image" mime="image/png" src="figures/trigger-capture.png" width="1280" height="800">
|
||||||
|
<p>The trigger marker sits at x = 0 and the pre-trigger share of the window is drawn
|
||||||
|
to its left.</p>
|
||||||
|
</media>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="first">
|
||||||
|
<title>The first five minutes</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p><em>Connect.</em> With no <cmd>--server</cmd>, pick a stored session or type a host
|
||||||
|
and port.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Enable a signal</em> in the <gui>Signals</gui> page (the <gui>+</gui> badge, or <key>g</key>). The plot stays empty for the first
|
||||||
|
fraction of a second while the first window fills — the buffer holds only what has
|
||||||
|
arrived.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Drag its badge onto a row.</em> 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.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Set the window</em> 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.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Pause</em> (<key>space</key>) to look at a still picture; the producer keeps
|
||||||
|
filling the buffer behind it, and resuming shows what arrived meanwhile rather than a
|
||||||
|
hole.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Arm the trigger</em> (<key>a</key>) 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.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Measure it</em> — press <key>m</key>, or <gui>Edit</gui> beside
|
||||||
|
<gui>Measurements</gui> in the dock. A measurement is a statistic of the visible
|
||||||
|
window, its value is shown in the dock, and <gui>show on plot</gui> marks it as a
|
||||||
|
ruler or a pair of markers.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Save the data</em> with <gui>Export visible range</gui>, which writes the
|
||||||
|
plotted signals over the window at the stored rate to <file>.npy</file>,
|
||||||
|
<file>.csv</file> or <file>.mf3</file> (MDF 3.30).</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>A signal nobody enables costs nothing — no buffer, no socket work, no
|
||||||
|
measurements.</p></item>
|
||||||
|
<item><p>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.</p></item>
|
||||||
|
<item><p>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.</p></item>
|
||||||
|
<item><p>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.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="installing">
|
||||||
|
<title>Installing this manual</title>
|
||||||
|
<p>The manual is a help set the desktop viewer reads off disk, so <key>F1</key> opens it only
|
||||||
|
once a copy sits where <cmd>help:</cmd> resolves. Either of two targets installs it on its own,
|
||||||
|
without building the application, and each prints where it wrote. Prefer the system-wide one:
|
||||||
|
it is the copy every viewer can read, and the reason is worth knowing before you pick the
|
||||||
|
other:</p>
|
||||||
|
<list>
|
||||||
|
<item><p>For a system-wide or packaged install:
|
||||||
|
<cmd>cmake --build build --target install-help</cmd> writes it under the configured
|
||||||
|
<cmd>CMAKE_INSTALL_PREFIX</cmd>, so point that at <file>/usr</file>
|
||||||
|
(<cmd>cmake -DCMAKE_INSTALL_PREFIX=/usr ..</cmd>) and wrap the target in
|
||||||
|
<cmd>sudo</cmd>. A viewer reads its figures as separate files, and its sandbox exposes the
|
||||||
|
system help directories, so this copy always shows them.</p></item>
|
||||||
|
<item><p>For your own account, needing no root:
|
||||||
|
<cmd>cmake --build build --target install-help-user</cmd> writes it under
|
||||||
|
<file>$XDG_DATA_HOME</file> (default <file>~/.local/share</file>). Be aware that the same
|
||||||
|
sandbox does <em>not</em> expose the user's data directory: those pages open, but a viewer
|
||||||
|
such as Yelp shows them without their figures.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="pause">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#viewing"/>
|
||||||
|
<link type="seealso" xref="trigger"/>
|
||||||
|
<desc>Pausing freezes the display without stopping the stream, and the status bar says how
|
||||||
|
long you have been paused.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Pause and play</title>
|
||||||
|
|
||||||
|
<p>Pause freezes the <em>display</em>, 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.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Press the pause button in the header bar, or <key>Space</key>.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Press it again to resume, which rebinds the live ring and puts the window back on
|
||||||
|
the newest data.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>Nothing is lost while paused. Because the producer never stops, resuming shows
|
||||||
|
current data with no hole in the history behind it.</p></item>
|
||||||
|
<item><p>The axis is pinned to the <em>snapshot's own stamps</em>, 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.</p></item>
|
||||||
|
<item><p>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. A measurement goes on describing
|
||||||
|
that frozen frame rather than the part of it you are looking at, so a zoom on a pause
|
||||||
|
changes no reading — see <link xref="measurements"/>.</p></item>
|
||||||
|
<item><p>Pausing is the easiest way to measure a layout or read a cursor precisely,
|
||||||
|
because nothing under the pointer moves between frames.</p></item>
|
||||||
|
<item><p>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.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads
|
||||||
|
<code>[-1.40 .. 0.00]</code> rather than <code>[-2.00 .. 0.00]</code> with 0.6 s of blank.
|
||||||
|
The pause probe reports <code>ring_produced</code> still growing across the pause and
|
||||||
|
<code>event_size</code> non-zero exactly while paused.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="sessions">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#output"/>
|
||||||
|
<link type="seealso" xref="source"/>
|
||||||
|
<desc>Per-source configuration files: where they live, what they persist, and how the
|
||||||
|
chooser and the Configurations dialog use them.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Sessions and settings</title>
|
||||||
|
|
||||||
|
<p>udpscope stores named sessions — <em>configurations</em> — per source, so reopening a
|
||||||
|
source gives you the scope you closed, and one source can hold several arrangements for
|
||||||
|
different experiments. There is no global preferences file: the session <em>is</em> the
|
||||||
|
settings.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<p>Nothing to open — the file is written as you work. Its path is in the About dialog, and
|
||||||
|
the directory is <file>$XDG_CONFIG_HOME/udpscope</file>, or <file>~/.config/udpscope</file>
|
||||||
|
when that is unset. Each source has a directory <file><host>_<port>/</file>
|
||||||
|
holding one <file><name>.toml</file> per configuration and an <file>active</file>
|
||||||
|
file naming the one it is on.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="configs">
|
||||||
|
<title>Configurations</title>
|
||||||
|
<p><gui>Configurations…</gui> in the menu lists the running source's configurations: click
|
||||||
|
a row to open it, <gui>×</gui> to delete it, or type a name and a note and press
|
||||||
|
<gui>Save this session</gui> to keep the current arrangement under a new one. Opening a
|
||||||
|
configuration saves the current session first, so switching never loses what it switches
|
||||||
|
from.</p>
|
||||||
|
|
||||||
|
<p>The startup chooser (start with no <cmd>--server</cmd>) lists one row per configuration,
|
||||||
|
with its note and its settings, so two arrangements of one source are told apart before
|
||||||
|
they open.</p>
|
||||||
|
|
||||||
|
<p>A configuration also carries the settings that can only be decided when the buffers are
|
||||||
|
allocated: the <gui>samples per signal</gui> (<cmd>--buffer-samples</cmd>), relative time, and the
|
||||||
|
packet capture's ring size. Opening a configuration from the chooser applies all three;
|
||||||
|
switching inside a running app applies the rest and says in the status line that these take
|
||||||
|
effect on the next start.</p>
|
||||||
|
|
||||||
|
<p>The chooser's form sets the first two for a new host, and its pencil button edits them
|
||||||
|
on an existing configuration — along with the name and the note. Renaming a configuration,
|
||||||
|
or editing the address it points at, moves it: the old file is removed rather than left
|
||||||
|
behind.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>What a configuration persists: the time window, the rows and each row's slot
|
||||||
|
signals, the calibration, the trace colours, which signals were enabled, the math
|
||||||
|
channels with their graphs, the measurements — and the startup settings: samples
|
||||||
|
per signal, relative time and the packet capture's ring size.</p></item>
|
||||||
|
<item><p>Signal identity is the <em>name</em> throughout, because a new CONFIG renumbers
|
||||||
|
the channels.</p></item>
|
||||||
|
<item><p>A file written by a run against a source whose streamer was down would name no
|
||||||
|
signals at all, so such a run does <em>not</em> write: a configured session is never
|
||||||
|
overwritten by a run that configured nothing. (The chooser hides such a file for the same
|
||||||
|
reason.)</p></item>
|
||||||
|
<item><p>The multicast group and the interface live <em>inside</em> 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.</p></item>
|
||||||
|
<item><p>The chooser lists only configurations that name at least one signal, which is
|
||||||
|
what keeps it the sessions you configured rather than every port you have ever tested
|
||||||
|
against.</p></item>
|
||||||
|
<item><p>Deleting a configuration is a supported way to start over; the chooser's
|
||||||
|
<gui>×</gui> and the dialog's do exactly that.</p></item>
|
||||||
|
<item><p>A session from before configurations existed is imported once as the
|
||||||
|
configuration <code>default</code>, and the old file is kept beside the directory as
|
||||||
|
<file><host>_<port>.toml.imported</file>.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>Connect to <code>127.0.0.1:8000</code>, drop three signals on row 1, set a 2 s window,
|
||||||
|
and save the session as <code>bench</code> with a note.
|
||||||
|
<file>~/.config/udpscope/127.0.0.1_8000/bench.toml</file> now holds
|
||||||
|
<code>window_s=2 comment="…"</code> with
|
||||||
|
<code>signals = ["sine", "ramp", "noise", ""]</code>, and the next run reports
|
||||||
|
<code>layout loaded from …/127.0.0.1_8000/bench.toml</code> with those signals already
|
||||||
|
acquired.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="signals">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#main"/>
|
||||||
|
<link type="seealso" xref="layout"/>
|
||||||
|
<desc>How the source's CONFIG becomes channels, what enabling a signal starts, and how a
|
||||||
|
signal's scale and offset are calibrated.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Signals and calibration</title>
|
||||||
|
|
||||||
|
<p>The source's CONFIG message describes its signals: a name, a unit and a type. udpscope
|
||||||
|
turns each enabled signal into a <em>channel</em> — 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.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Click the <gui>+</gui> badge in the badge bar, or press <key>g</key>. The
|
||||||
|
<gui>Signals</gui> page lists what can be added — the signals the source declares that
|
||||||
|
are not acquired yet, each with a tick that acquires it. Past six acquired signals the
|
||||||
|
badges fold into one <gui>N signals</gui> badge, and the page then lists every signal.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Once a signal is acquired its badge appears. Click it to open that signal's page: its
|
||||||
|
title carries the signal's name and its channel number (<code>2. sine</code>), and its
|
||||||
|
details follow — <gui>Unit</gui>, <gui>Gain</gui> and <gui>Offset</gui>,
|
||||||
|
each with a pencil button that edits that one value, then the type, size, range, sample
|
||||||
|
rate and timing the stream declared — its colour and <gui>Plot on</gui> buttons for the
|
||||||
|
rows.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>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.</p></item>
|
||||||
|
<item><p>A signal's page shows the gain and offset it is <em>currently</em> drawn
|
||||||
|
through, so a trace already scaled by a gain of 2 reads 2. The pencil beside a number opens
|
||||||
|
an editor for that number only, at its current value —
|
||||||
|
pressing <gui>Apply</gui> confirms it rather than resetting it. A signal nobody
|
||||||
|
calibrated shows the identity, 1 and 0. The unit can be renamed the same way (an empty
|
||||||
|
entry restores the one the stream declares); the new name is used in the readouts, the
|
||||||
|
measurements and the export, and is remembered with the session.</p></item>
|
||||||
|
<item><p>Calibration is applied <em>at ingest</em>, 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.</p></item>
|
||||||
|
<item><p>The stream's first signal is usually its <em>timestamp</em>, 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.</p></item>
|
||||||
|
<item><p>A signal's colour follows the <em>signal</em>, not the slot, so the same signal
|
||||||
|
on two rows is one colour, and the dot on its badge shows it. A signal on no row gets a
|
||||||
|
grey dot rather than none.</p></item>
|
||||||
|
<item><p><em>Change a signal's colour</em> with <gui>Change colour</gui> on its page; a
|
||||||
|
signal on no row has no trace to colour, so the button is dimmed. The colour is saved
|
||||||
|
with the session; <gui>Default colour</gui> on the same page gives the palette entry
|
||||||
|
back.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>A source declaring <code>time</code>, <code>sine</code>, <code>ramp</code> and
|
||||||
|
<code>noise</code> yields three plottable signals. Enable all three and the exit dump
|
||||||
|
reads <code>3 signals, 3 channels</code>; <code>time</code> is not among them.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="source">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#main"/>
|
||||||
|
<link type="seealso" xref="sessions"/>
|
||||||
|
<desc>How udpscope is pointed at a source: the command line, the startup chooser, and
|
||||||
|
opening an exported waveform instead of a live stream.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Choosing a source</title>
|
||||||
|
|
||||||
|
<p>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.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Run with <cmd>--server</cmd> and the app connects straight away.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Run <em>without</em> <cmd>--server</cmd> and a chooser appears first: one row per
|
||||||
|
stored <em>configuration</em> (<code>host:port · name</code>, its note, and the
|
||||||
|
settings it carries), newest first, plus a <gui>New host…</gui> form that takes the
|
||||||
|
address <em>and</em> the configuration it belongs to — its name and note, the
|
||||||
|
<gui>samples per signal</gui>, and whether time is relative or absolute. A row's pencil
|
||||||
|
button opens the same form on that configuration, pre-filled, and changes to its name
|
||||||
|
or address move it (the old file is deleted).</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The second page, <gui>Waveforms</gui>, opens an <em>exported</em> run instead of a
|
||||||
|
live source: the files opened before (newest first; one that has since been deleted is
|
||||||
|
marked <em>missing</em>) and an <gui>Open…</gui> button for a new one. The same file
|
||||||
|
can be opened straight from the command line with <cmd>--open FILE</cmd>. A waveform
|
||||||
|
is a source that never arrives — no socket, no acquisition, no starvation notice — and
|
||||||
|
every instrument works on it: cursors, measurements, math channels and another export.
|
||||||
|
Only <file>.npy</file> files are read.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
|
||||||
|
<p>The chooser runs before any buffer is allocated and before any socket is opened, so
|
||||||
|
declining it costs nothing.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>The list is the sessions that <em>named a signal</em>. 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.</p></item>
|
||||||
|
<item><p>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
|
||||||
|
(<file>host_port.toml</file>) cannot carry them.</p></item>
|
||||||
|
<item><p><cmd>--multicast</cmd> still needs <cmd>--server</cmd>: the CONFIG message is
|
||||||
|
fetched from the server, not from the group.</p></item>
|
||||||
|
<item><p>The <gui>×</gui> beside a row deletes that session file. Nothing else is
|
||||||
|
removed.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p><cmd>udpscope</cmd> with two stored sessions lists
|
||||||
|
<code>127.0.0.1:8000 plots=1 signals=3</code> and
|
||||||
|
<code>10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9</code>.
|
||||||
|
Activating the first reports <code>[sources] picked 127.0.0.1:8000</code> and continues
|
||||||
|
into the scope with that session's layout already loaded.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="status">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#output"/>
|
||||||
|
<desc>The status bar's two tiers, the activity badge, the fixed state words, and the ⓘ
|
||||||
|
detail popover.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Status bar and diagnostics</title>
|
||||||
|
|
||||||
|
<p>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.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<p>The bar is always visible at the foot of the window, under the badge bar. Click <gui>ⓘ</gui> for the detail popover.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>The badge is one glyph in four colours: green <em>alternating</em> 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, <em>grey</em> is no stream at all or a
|
||||||
|
pipeline you stopped (the tooltip says which), and <em>yellow</em> is a source that
|
||||||
|
answered but has sent no configuration.</p></item>
|
||||||
|
<item><p>Left of the badge is the <em>trigger circle</em>, and it is the trigger's own
|
||||||
|
light: solid green while the trigger has fired (an event is held, or a capture is
|
||||||
|
filling), <em>flashing</em> green while armed and waiting for an event, red while the
|
||||||
|
trigger mode is on but disarmed, and hidden entirely while the trigger mode is off — a
|
||||||
|
light for a mode you are not in is noise. Its tooltip names the state in words.</p></item>
|
||||||
|
<item><p>The state field is one word from a fixed set: <code>connecting</code>,
|
||||||
|
<code>live</code>, <code>paused</code>, <code>armed</code>, <code>capturing</code>,
|
||||||
|
<code>event held</code>, <code>disarmed</code>. Two states add one field each: in trigger
|
||||||
|
mode, how long ago the last event was; while paused, how long you have been paused.</p></item>
|
||||||
|
<item><p><code>connecting</code> means there is no <em>signal list</em> 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.</p></item>
|
||||||
|
<item><p>The paused figure is <em>wall</em> time, not stream time — the producer never
|
||||||
|
stops while paused, so it answers "how long have I been looking at this picture".</p></item>
|
||||||
|
<item><p>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.</p></item>
|
||||||
|
<item><p><code>gaps</code> 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.</p></item>
|
||||||
|
<item><p><code>reconnects</code> counts sessions re-established with the source. Over
|
||||||
|
unicast a climbing count usually means a second client is taking the source's single
|
||||||
|
session.</p></item>
|
||||||
|
<item><p>The starvation notice — <em>connected, but no data is arriving</em> — 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 <em>no</em> configuration gets a different notice,
|
||||||
|
<em>trying to connect</em>, 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.</p></item>
|
||||||
|
<item><p><code>stored</code> versus <code>wire</code> in the popover is the decimation.
|
||||||
|
They differ exactly when the window is too long to store at full rate.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>The bar reads <code>● live · 10 kS/s · 2 s window</code>. Behind ⓘ:
|
||||||
|
<code>Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3
|
||||||
|
channels</code>, <code>Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire
|
||||||
|
10 kS/s)</code>, <code>Plot 1 3 traces · 195000 samples</code>,
|
||||||
|
<code>Reconnects 0</code>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="trigger">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#viewing"/>
|
||||||
|
<link type="seealso" xref="pause"/>
|
||||||
|
<desc>The trigger's four shapes, arming, forcing an acquisition and hold-off, the event cache with its replay,
|
||||||
|
compare and accumulate, and searching the buffer for a condition.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Trigger</title>
|
||||||
|
|
||||||
|
<p>The trigger watches one signal for a condition and captures a window around it. The
|
||||||
|
<gui>Shape</gui> dropdown chooses what counts as an event; the arming, hold-off, pre/post
|
||||||
|
window and Normal/Single mode are the same for all four shapes. The captured window is
|
||||||
|
frozen on screen until you re-arm.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Click the <gui>Trigger</gui> badge at the right of the badge bar to open the
|
||||||
|
trigger page: the signal, the level, the shape, the edge (rising, falling, either), the
|
||||||
|
mode (Normal or Single) and the pre-trigger share. The trigger button in the header bar
|
||||||
|
(<key>t</key>) turns trigger mode on and off; it does not open the page.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><gui>Arm</gui> starts watching; <key>a</key> arms and disarms from the keyboard.
|
||||||
|
When the page is locked with its edit button — so that it states what the
|
||||||
|
trigger is set to instead of showing the controls — <gui>Arm/Disarm</gui> is in its
|
||||||
|
heading, and the trigger can be armed or disarmed without unfolding the page. It is
|
||||||
|
offered only while trigger mode is on, because a trigger that is off has no engine to
|
||||||
|
arm.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><gui>Force</gui> takes one acquisition <em>now</em>, with the event window above,
|
||||||
|
without waiting for the condition to hold — for when the event you are waiting for is not
|
||||||
|
coming and you want to see what the signal is doing this instant. The capture runs out its
|
||||||
|
post-trigger share and then freezes, exactly like a latch: the marker sits at the instant
|
||||||
|
you pressed. In <gui>Normal</gui> mode the trigger goes back to waiting afterwards; in
|
||||||
|
<gui>Single</gui> mode it disarms, because one acquisition is the whole of what that mode
|
||||||
|
means either way. It needs a signal to be acquired — the trigger cannot watch one that is
|
||||||
|
switched off — and pressing it while a capture is already filling leaves that capture
|
||||||
|
alone, since it is the acquisition you asked for.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><gui>Search the buffer</gui> is at the foot of the same panel.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<title>A held capture</title>
|
||||||
|
<desc><gui>event held</gui> in the status bar, the axis pinned to the event, and the
|
||||||
|
trigger marker at x = 0.</desc>
|
||||||
|
<media type="image" mime="image/png" src="figures/trigger-capture.png" width="1280" height="800">
|
||||||
|
<p>The pre-trigger share is drawn to the left of the marker.</p>
|
||||||
|
</media>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="shapes">
|
||||||
|
<title>The four shapes</title>
|
||||||
|
<terms>
|
||||||
|
<item>
|
||||||
|
<title>Edge</title>
|
||||||
|
<p>A crossing of the level in the chosen direction, the classic behaviour.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Pulse width</title>
|
||||||
|
<p>A crossing and its return, qualified by how long the excursion lasted: less than,
|
||||||
|
greater than, within or outside a pair of widths; the event is timed at the closing
|
||||||
|
edge.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Window</title>
|
||||||
|
<p>The signal entering or leaving a band between two levels; the panel keeps the two
|
||||||
|
levels at least one increment apart, because a zero-width band is not a band: the
|
||||||
|
condition becomes an exact float-equality test against the level, which an ordinary
|
||||||
|
signal never satisfies and a signal sitting at the level satisfies on every
|
||||||
|
transition.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Dropout</title>
|
||||||
|
<p>The absence of a crossing for longer than a timeout; one event per outage, re-arms
|
||||||
|
when a crossing returns.</p>
|
||||||
|
</item>
|
||||||
|
</terms>
|
||||||
|
|
||||||
|
<p>The shape's own rows are the only ones shown; the rest are hidden, so the panel never
|
||||||
|
offers a pulse width to a window trigger.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="multiple-events">
|
||||||
|
<title>Multiple events: keeping the last few acquisitions</title>
|
||||||
|
<p><gui>multiple events</gui> decides how many of the most recent triggered acquisitions stay on
|
||||||
|
screen at once. It is <em>1</em> by default, which is the event alone — the display you get
|
||||||
|
when you ask for no persistence — and it can be raised to 8. Each older acquisition is drawn
|
||||||
|
at half the brightness of the one before it (1, 0.5, 0.25, 0.125, …), and they are all aligned
|
||||||
|
on their own trigger edge, so the pre-trigger part of the event lines up from acquisition to
|
||||||
|
acquisition and whatever changes between them — jitter, ringing, the occasional glitch — shows
|
||||||
|
as a faint ghost behind the current trace. That is what a phosphor display does with
|
||||||
|
successive sweeps.</p>
|
||||||
|
|
||||||
|
<p>The older acquisitions are drawn <em>behind</em> the current one and nothing else changes
|
||||||
|
because of them: the axis still frames the event on screen, the trigger marker still marks
|
||||||
|
<em>its</em> edge, and the measurements still describe the current frame. Lowering
|
||||||
|
<gui>multiple events</gui> back to 1 remembers nothing and frees the retained acquisitions
|
||||||
|
immediately.</p>
|
||||||
|
|
||||||
|
<p>Two things bound how many are drawn, and the panel tells you when one of them bites.</p>
|
||||||
|
<list>
|
||||||
|
<item><p><em>Room in the plot.</em> Each older acquisition is an extra trace in the row that
|
||||||
|
plots its signal, so it needs a free trace slot there. A row holding four signals — the
|
||||||
|
layout the app starts with — has room for one older acquisition of each of them; a row per
|
||||||
|
signal has room for all seven.</p></item>
|
||||||
|
<item><p><em>Memory.</em> A retained acquisition is a whole window copied out of the ring,
|
||||||
|
so persistence may hold at most as much again as the buffer takes (see <cmd>--buffer-samples</cmd> and <cmd>--memory-limit-mb</cmd>).
|
||||||
|
On a long window at a high sample rate that bound is reached before the eighth layer.</p></item>
|
||||||
|
</list>
|
||||||
|
<section id="persistence">
|
||||||
|
<title>Persistence: how often the trace has been here</title>
|
||||||
|
<p><gui>persistence</gui> — the switch in the trigger panel's <gui>Advanced</gui> block — keeps
|
||||||
|
an <em>image</em> of the last acquisitions instead of their traces. Every acquisition is
|
||||||
|
accumulated into it, the one on screen included, so a cell's brightness means how many of them
|
||||||
|
passed through it: the sweep on screen is the brightest, the ones behind it add on top, and
|
||||||
|
where several agree the ink is brighter still. There is no stroke drawn over the image — the
|
||||||
|
acquisition on screen <em>is</em> its newest ink — so it fades with the rest in both modes. Each
|
||||||
|
signal is kept as its own image in that signal's own colour, so where two signals cross the same
|
||||||
|
place you see both, not one blended line. It is off by default.</p>
|
||||||
|
|
||||||
|
<p>Two things make it different from <gui>multiple events</gui> beside it, and the panel offers
|
||||||
|
both because they answer different questions. <em>It never runs out of room</em>: each signal's
|
||||||
|
image is sized to the plot on screen and capped, so a display of a thousand acquisitions costs
|
||||||
|
what a display of one costs — one image per signal, whatever the plot's trace slots, and a very
|
||||||
|
wide window does not make it grow — and there is no count to choose. <em>And it fades</em>, in
|
||||||
|
one of two ways — <gui>fades by</gui> picks them:</p>
|
||||||
|
<list>
|
||||||
|
<item><p><gui>acquisitions</gui>: every acquisition keeps
|
||||||
|
<gui>per acquisition</gui> of what was there before it (0.5, the default, is the schedule a
|
||||||
|
phosphor's afterglow has: 1, 0.5, 0.25, …). The display then shows the last few sweeps
|
||||||
|
whatever the trigger rate.</p></item>
|
||||||
|
<item><p><gui>time</gui>: the image fades by <gui>time constant s</gui> as the display runs —
|
||||||
|
one constant is the time for it to fall to a third of its ink — so a slow trigger leaves
|
||||||
|
faint sweeps and a fast one leaves bright ones, and the picture is the same whichever the
|
||||||
|
stream is doing.</p></item>
|
||||||
|
</list>
|
||||||
|
|
||||||
|
<p>Persistence is a <em>trigger mode</em> display, like the trigger itself: it is drawn while a
|
||||||
|
captured acquisition is on screen and goes away with the trigger, on a pause, and when you open
|
||||||
|
a stored waveform. Nothing about it moves the framing — the axis is still the current event's,
|
||||||
|
the older sweeps only add ink — and it reserves no space in the plot and cannot be clicked, so
|
||||||
|
it is never in the way of a cursor or a drop.</p>
|
||||||
|
|
||||||
|
<p>Both displays live in the trigger panel's <gui>Advanced</gui> block, together with the
|
||||||
|
trigger's other settings that are set up once and left alone.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
|
||||||
|
<p>If a depth cannot be shown in full, a notice says how many layers fit, and the panel's
|
||||||
|
tooltip carries the same rule. Persistence is a display of what has already been captured: it
|
||||||
|
changes neither what is acquired nor what is measured, and it does nothing while the trigger
|
||||||
|
is off.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="cache">
|
||||||
|
<title>The event cache, replay and compare</title>
|
||||||
|
<p><em>Every triggered acquisition is kept.</em> Latches are written to the event cache
|
||||||
|
(<file>~/.cache/udpscope/events/<source>/<configuration>/</file> -- one folder per
|
||||||
|
source and configuration, so two configurations of one source keep their events apart) as a
|
||||||
|
<file>.npy</file> of the
|
||||||
|
event, a <file>.txt</file> of what it was, and a <file>.bmp</file> picture drawn from the
|
||||||
|
samples. The <gui>Events</gui> block at the foot of the dock lists them, newest first, one
|
||||||
|
short row each: a marker for the event on screen, its title and its number. It is dimmed
|
||||||
|
until recording is on, an event is stored or one is being replayed. <gui>Manage</gui>
|
||||||
|
beside its heading opens the <gui>Events</gui> page over the plot, which keeps the last 32
|
||||||
|
(time, duration, signals, rate), each row with its own picture beside the numbers.</p>
|
||||||
|
|
||||||
|
<p>Clicking a row in the dock <em>replays</em> it: the stored event is loaded exactly like an exported
|
||||||
|
file, <em>without changing the layout</em> — the rows, the colours, the window, the
|
||||||
|
cursors and the measurements stay where they are and read the stored data instead. A
|
||||||
|
replay leaves trigger mode, because a file has no edge to wait for. <gui>Live</gui>, on the
|
||||||
|
<gui>Events</gui> page, goes back to the stream; the status bar names the file until
|
||||||
|
you do.</p>
|
||||||
|
|
||||||
|
<p>On the <gui>Events</gui> page, select two or more events in the list and the two buttons
|
||||||
|
under its header act on them:
|
||||||
|
<gui>Compare</gui> puts two events in two plots side by side, keeping the layout you have
|
||||||
|
— one plot per event, the axes synchronised, so zooming a row in one zooms the same row in
|
||||||
|
the other — and <gui>Accumulate</gui> overlays up to four of them in the same traces,
|
||||||
|
translucent, so where they agree the trace is brighter. Your own traces stay where they
|
||||||
|
are in both. They need events with the same signals and the same length. On this page a
|
||||||
|
single click selects and a <em>double</em> click replays.</p>
|
||||||
|
|
||||||
|
<p><gui>Record events</gui>, on the trigger page with the trigger's other options, is
|
||||||
|
what decides whether anything is kept. It is <em>off by default</em> — nothing is written
|
||||||
|
to disk until you ask — and with it off, and nothing stored, the dock's events block and
|
||||||
|
the page stay dimmed.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="search">
|
||||||
|
<title>Search the buffer</title>
|
||||||
|
<p><gui>Search the buffer</gui>, at the foot of the trigger panel and collapsed until you open
|
||||||
|
it, answers the question the trigger answers — <em>where does this condition hold?</em> — over
|
||||||
|
the <em>whole</em> retained buffer rather than at the live edge. It runs the trigger's own
|
||||||
|
evaluator over the ring in stream order, so a <gui>hit</gui> is not a level you point at: it is
|
||||||
|
an <em>instant</em> the trigger would have fired on, timed the way an acquisition would have
|
||||||
|
timed it. A pulse-width finding, for instance, is the closing edge of a qualifying excursion,
|
||||||
|
not the excursion itself.</p>
|
||||||
|
|
||||||
|
<p>The condition is the one the rest of the panel is set to — the same signal, shape, level and
|
||||||
|
options — so there is no second condition to keep in step: change the panel and the next search
|
||||||
|
asks the new question. Nothing is searched until you ask, and the status line says
|
||||||
|
<gui>not searched</gui>.</p>
|
||||||
|
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Set the condition above, then press <gui>Search</gui>. Covering the whole ring takes a
|
||||||
|
moment, and the scan runs in chunks so the stream keeps arriving while it does; the status
|
||||||
|
line counts the ground covered — <gui>scanning 1.2 s of 8.0 s, 40 found</gui>.
|
||||||
|
<gui>Stop</gui> ends a scan early and keeps the hits found so far.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Step through the hits with the arrows either side of the position readout
|
||||||
|
(<gui>3 of 40</gui>). The first <gui>next</gui> goes to the first hit, and the ends do not
|
||||||
|
wrap — a wrap from the last hit to the first would look exactly like the view not having
|
||||||
|
moved. Each step <em>pauses</em> on its hit, centred, exactly as the Pause button does, so
|
||||||
|
what you read is the frozen window cut around that instant; press the pause button again to
|
||||||
|
go back to the stream.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Every plot row marks the hits that fall in the window, up to sixteen of them. When more
|
||||||
|
are in view the pane says so and suggests zooming in — <gui>16 of 38 marks shown — zoom in
|
||||||
|
to see the rest</gui> — and when the selected hit has scrolled off the window it says that
|
||||||
|
instead. A hit the live stream has since overwritten reads <gui>no longer in the buffer</gui>
|
||||||
|
beside its position.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
|
||||||
|
<p>How a scan ended is part of the answer: <gui>N found in 8.0 s</gui> when it covered the whole
|
||||||
|
ring; <gui>N found; the stream overtook the oldest samples</gui> when the producer overwrote
|
||||||
|
samples before the scan reached them, so the count is of what was left; and
|
||||||
|
<gui>N found (the limit) in the newest …</gui> when a condition that holds almost everywhere —
|
||||||
|
an edge on a noisy signal — reached the ten-thousand-hit ceiling. The last is a stop, not a
|
||||||
|
crash: a hit list nobody could navigate is not worth building.</p>
|
||||||
|
|
||||||
|
<p>A result describes the ring it was taken from, so it is <em>dropped</em> when that ring is
|
||||||
|
replaced: a new <gui>CONFIG</gui> from the source, or a change to the signal set, re-describes
|
||||||
|
the stream and clears the hits. The condition above is untouched — only the answer to it
|
||||||
|
is.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>Arming <em>enables</em> the signal if it was off, because a trigger on a signal
|
||||||
|
nobody acquires can never fire.</p></item>
|
||||||
|
<item><p>The pre-trigger share is a share of the <em>display window</em>, not of the
|
||||||
|
ring. A 2 s window at 50% captures 1 s before the edge and 1 s after.</p></item>
|
||||||
|
<item><p>There are four display states, and the status bar names each: <gui>armed</gui>
|
||||||
|
(watching, nothing drawn yet), <gui>capturing</gui> (the edge was accepted and the post
|
||||||
|
window is filling <em>in view</em>, with the marker at x = 0), <gui>event held</gui>
|
||||||
|
(the frozen capture) and <gui>disarmed</gui>. A held capture is never quietly replaced
|
||||||
|
by live data.</p></item>
|
||||||
|
<item><p>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. <gui>Force</gui> is the one
|
||||||
|
acquisition that skips that gate — it captures the history that exists, because "now"
|
||||||
|
is the whole of what you asked for — so on a ring that has just started filling its
|
||||||
|
left side is short even though the axis still shows only what it got.</p></item>
|
||||||
|
<item><p>Disarming <em>during</em> 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.</p></item>
|
||||||
|
<item><p>The trigger reads the <em>stored</em> 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.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans
|
||||||
|
<code>[-1.000 .. 1.000]</code> with <code>n=20001</code> samples at 10 kS/s — the span
|
||||||
|
times the real rate, plus one — and the marker sits at x = 0 throughout.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="window">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#viewing"/>
|
||||||
|
<link type="seealso" xref="navigate"/>
|
||||||
|
<desc>The time window control: how to set it, and why a long window is decimated rather
|
||||||
|
than refused.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Time window</title>
|
||||||
|
|
||||||
|
<p>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 <em>this</em> window.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>The header bar's window button opens a popover: type a number, pick a unit under
|
||||||
|
it, or step a decade with the arrows.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>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.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>A window too long to store at the wire rate is <em>decimated, not refused</em>.
|
||||||
|
The shared clock takes a power-of-two stride, and each group of samples is
|
||||||
|
<em>averaged</em> rather than strided, so a tone above the stored Nyquist is attenuated
|
||||||
|
instead of aliasing to a false low frequency.</p></item>
|
||||||
|
<item><p>The stamp stored for a group is the group's <em>mean</em>, which is where the
|
||||||
|
averaging puts the value. Stamping at the end of a group would shift every trace half a
|
||||||
|
stored sample late.</p></item>
|
||||||
|
<item><p>Moving the window across a decimation boundary rebuilds the buffer and drops
|
||||||
|
the history. Walking it inside one decade is free.</p></item>
|
||||||
|
<item><p><em>This control is the only thing that changes the window.</em> A zoom, a pan
|
||||||
|
or a fit moves the view <em>inside</em> 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.</p></item>
|
||||||
|
<item><p>The trigger sees the <em>stored</em> (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.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>1 MS/s with the default buffer of 8 388 608 samples per signal (8.39 s). A 2 s window
|
||||||
|
stores every sample (<code>time x1</code>). Widen it to 600 s and the stride becomes
|
||||||
|
<code>x128</code>: 7.8 kS/s stored, anti-aliased at 3.9 kHz. A 5 kHz sine seen through that
|
||||||
|
window is attenuated by the filter, not folded down to a full-amplitude low-frequency one.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -3,24 +3,27 @@
|
|||||||
|
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include "capture.hpp"
|
||||||
#include "config.hpp"
|
#include "config.hpp"
|
||||||
#include "model.hpp"
|
#include "model.hpp"
|
||||||
#include "trigger.hpp"
|
#include "trigger.hpp"
|
||||||
|
|
||||||
#include <udps_client.h>
|
#include "source/data_source.hpp"
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
#include <chrono>
|
#include <chrono>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
#include <limits>
|
||||||
|
#include <memory>
|
||||||
#include <thread>
|
#include <thread>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
|
|
||||||
/// Owns the UDPStreamer client and the producer thread that feeds the model's
|
/// Owns the data source and the producer thread that feeds the model's
|
||||||
/// acquisition buffer. Everything it touches from the thread is serialised on
|
/// acquisition buffer. Everything it touches from the thread is serialised on
|
||||||
/// `ScopeModel::mutex()`, so a UI-side structural change cannot race a frame.
|
/// `ScopeModel::mutex()`, so a UI-side structural change cannot race a frame.
|
||||||
class Acquisition {
|
class Acquisition : private SourceSink {
|
||||||
public:
|
public:
|
||||||
Acquisition(ScopeModel& model, const Config& cfg);
|
Acquisition(ScopeModel& model, const Config& cfg);
|
||||||
~Acquisition();
|
~Acquisition();
|
||||||
@@ -28,8 +31,8 @@ class Acquisition {
|
|||||||
Acquisition(const Acquisition&) = delete;
|
Acquisition(const Acquisition&) = delete;
|
||||||
Acquisition& operator=(const Acquisition&) = delete;
|
Acquisition& operator=(const Acquisition&) = delete;
|
||||||
|
|
||||||
/// Opens the client (no socket until the first poll) and starts the thread.
|
/// Opens the source (no socket until the first poll) and starts the thread.
|
||||||
/// Throws std::runtime_error when the client cannot be created.
|
/// Throws std::runtime_error when the source cannot be created.
|
||||||
void start();
|
void start();
|
||||||
/// Stops the thread and destroys the client. Idempotent.
|
/// Stops the thread and destroys the client. Idempotent.
|
||||||
void stop();
|
void stop();
|
||||||
@@ -37,28 +40,95 @@ class Acquisition {
|
|||||||
void set_paused(bool paused) noexcept { paused_.store(paused, std::memory_order_relaxed); }
|
void set_paused(bool paused) noexcept { paused_.store(paused, std::memory_order_relaxed); }
|
||||||
bool paused() const noexcept { return paused_.load(std::memory_order_relaxed); }
|
bool paused() const noexcept { return paused_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
/// Turns the packet capture on or off -- the whole GTK side of the feature. The
|
||||||
|
/// path is built here, once, *before* the flag is published, so the producer reads
|
||||||
|
/// a complete string whenever it reads a true flag. `slots` is the ring's size;
|
||||||
|
/// zero means the 1000-slot default. The producer does the open or the close at the
|
||||||
|
/// top of the next datagram, so a toggle costs no lock and no second thread.
|
||||||
|
void set_capture_want(bool want, std::uint32_t slots = 0);
|
||||||
|
bool capture_want() const noexcept {
|
||||||
|
return capture_want_.load(std::memory_order_acquire);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks for one **forced trigger acquisition** -- the trigger panel's Force button. The producer
|
||||||
|
/// consumes the request on its next packet and opens a capture at that packet's newest stamp,
|
||||||
|
/// which the engine's ordinary latch then publishes with the configured pre/post window. The
|
||||||
|
/// request is dropped rather than retried when there is nothing to force: trigger mode off, no
|
||||||
|
/// channel resolved, the engine disarmed, or a capture already filling (a capture in flight *is*
|
||||||
|
/// the acquisition being asked for).
|
||||||
|
///
|
||||||
|
/// **Write it under `ScopeModel::mutex()`, together with the `armed` flag the click also sets.**
|
||||||
|
/// The producer holds that lock for a whole packet -- it reads the panel's `armed` at the top of
|
||||||
|
/// one and consumes this request at the bottom -- so a request written between the two would be
|
||||||
|
/// read by a packet whose engine was still disarmed, and then dropped.
|
||||||
|
void request_force() noexcept { force_want_.store(true, std::memory_order_release); }
|
||||||
|
|
||||||
|
/// The source this acquisition drives, for the UI's capability panels: it may only call
|
||||||
|
/// `post()` on it. Null until `start()`.
|
||||||
|
DataSource* source() noexcept { return source_.get(); }
|
||||||
|
|
||||||
|
/// The capture, for the popover's row and the probe. Counters and errors are read
|
||||||
|
/// cross-thread through PacketCapture's own published fields.
|
||||||
|
const PacketCapture& capture() const noexcept { return capture_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void run();
|
void run();
|
||||||
void on_config(const udps_signal_t* signals, std::uint32_t count, std::uint8_t publish_mode);
|
void on_config(const std::vector<SignalInfo>& signals, std::uint8_t publish_mode) override;
|
||||||
void on_data(const udps_frame_t* frame);
|
void on_data(const Frame& frame) override;
|
||||||
void on_event(int event, const char* detail);
|
void on_event(SourceEvent event, const char* detail) override;
|
||||||
|
/// Drops the producer state that is per *connection*: the counter mark, the time
|
||||||
static void config_trampoline(const udps_signal_t* signals, std::uint32_t count,
|
/// anchors and the clock choice. A reconnect re-sends the CONFIG and the stream may
|
||||||
std::uint8_t publish_mode, void* user);
|
/// have restarted its numbering; carrying the old session's marks across makes the
|
||||||
static void data_trampoline(const udps_frame_t* frame, void* user);
|
/// first new frames read as late, as glitches or as an outage, which is recovery
|
||||||
static void event_trampoline(udps_event_t event, const char* detail, void* user);
|
/// failing exactly when it matters. Called on the producer thread, from on_event().
|
||||||
|
void reset_session_state() noexcept;
|
||||||
|
/// The producer side of the toggle: open on a rising edge, close on a falling one,
|
||||||
|
/// then store if enabled. Every file operation happens here, on this thread.
|
||||||
|
void on_datagram(const std::uint8_t* bytes, std::size_t len, double recv_time);
|
||||||
|
|
||||||
/// Fills `stamps_` with this frame's base-tick timestamps, in stream seconds,
|
/// Fills `stamps_` with this frame's base-tick timestamps, in stream seconds,
|
||||||
/// and returns how many were written (`frame->num_samples` unless the frame is
|
/// and returns how many were written (`frame.num_samples` unless the frame is
|
||||||
/// malformed).
|
/// malformed).
|
||||||
std::uint32_t build_stamps(const udps_frame_t* frame, int time_signal);
|
std::uint32_t build_stamps(const Frame& frame, int time_signal,
|
||||||
|
bool counter_gap_refused);
|
||||||
|
|
||||||
|
/// Pushes NaN for the `missing` packets' worth of samples that were never delivered,
|
||||||
|
/// advances the decimation accumulators and tick_, records the hole in the model, and
|
||||||
|
/// bumps the hole counters. Called before the frame's own samples so the gap sits
|
||||||
|
/// *ahead* of the data that revealed it.
|
||||||
|
void reserve_hole(std::uint32_t missing, const Partition& part,
|
||||||
|
const std::vector<ChannelInfo>& channels, BufferStore& store, u64 g);
|
||||||
|
|
||||||
|
/// Writes a late frame's values into the NaN-filled holes their counter addresses,
|
||||||
|
/// amending the value ring directly (producer path) and queuing pyramid amends for
|
||||||
|
/// `ChannelBuffer::acquire()` to apply on the consumer thread.
|
||||||
|
void backfill_from(const Frame& frame, const Partition& part,
|
||||||
|
const std::vector<ChannelInfo>& channels, BufferStore& store, u64 g);
|
||||||
|
|
||||||
|
/// Keeps the wire's own cadence up to date from the arrival times, in the modes where
|
||||||
|
/// `build_stamps()`'s arrival branch does not (see the definition).
|
||||||
|
void note_frame_arrival(double recv_time) noexcept;
|
||||||
|
|
||||||
|
/// Whether the counter's claim of `missing` lost packets can be believed *and* booked: the
|
||||||
|
/// arrival clock has to corroborate it and the ring has to be able to hold it. See the
|
||||||
|
/// definition for both tests and for what believing a claim that fails them looks like.
|
||||||
|
bool gap_plausible(std::uint32_t missing, double elapsed, const Partition& part, u64 g) const;
|
||||||
|
|
||||||
ScopeModel& model_;
|
ScopeModel& model_;
|
||||||
Config cfg_;
|
Config cfg_;
|
||||||
udps_client* client_ = nullptr;
|
std::unique_ptr<DataSource> source_;
|
||||||
std::thread thread_;
|
std::thread thread_;
|
||||||
std::atomic<bool> running_{false};
|
std::atomic<bool> running_{false};
|
||||||
std::atomic<bool> paused_{false};
|
std::atomic<bool> paused_{false};
|
||||||
|
PacketCapture capture_;
|
||||||
|
std::atomic<bool> capture_want_{false};
|
||||||
|
/// The pending Force request (see `request_force()`): a flag and not a queue, because two clicks
|
||||||
|
/// inside one packet are one acquisition, which is what a momentary button means.
|
||||||
|
std::atomic<bool> force_want_{false};
|
||||||
|
std::uint32_t capture_slots_ = 1000;
|
||||||
|
/// Written once, by the GTK thread, before `capture_want_` is ever published true;
|
||||||
|
/// the producer reads it only after the flag, so no lock is needed.
|
||||||
|
std::string capture_path_;
|
||||||
TriggerEngine engine_;
|
TriggerEngine engine_;
|
||||||
|
|
||||||
std::uint64_t tick_ = 0; ///< base ticks consumed since the epoch
|
std::uint64_t tick_ = 0; ///< base ticks consumed since the epoch
|
||||||
@@ -70,25 +140,92 @@ class Acquisition {
|
|||||||
/// included -- so the stream source is dropped in favour of arrival time.
|
/// included -- so the stream source is dropped in favour of arrival time.
|
||||||
double last_stream_anchor_ = 0.0;
|
double last_stream_anchor_ = 0.0;
|
||||||
bool have_stream_anchor_ = false;
|
bool have_stream_anchor_ = false;
|
||||||
|
/// Consecutive packets whose stream-time stamps failed those checks. A single bad
|
||||||
|
/// packet is a glitch in the producer's clock and is bridged at the declared cadence;
|
||||||
|
/// only a run of them means the declared signal is not a time base, and only then is
|
||||||
|
/// the arrival clock taken. Any accepted packet resets it.
|
||||||
|
unsigned stamp_failures_ = 0;
|
||||||
/// Arrival-clock origin: the first packet's receive time, so arrival stamps are
|
/// Arrival-clock origin: the first packet's receive time, so arrival stamps are
|
||||||
/// small seconds (the time ring stores f32, which cannot hold Unix epochs).
|
/// small seconds (the time ring stores f32, which cannot hold Unix epochs).
|
||||||
double arrival_epoch_ = 0.0;
|
double arrival_epoch_ = 0.0;
|
||||||
bool have_arrival_epoch_ = false;
|
bool have_arrival_epoch_ = false;
|
||||||
/// Smoothed interval between two frames' arrivals, and the previous arrival: the
|
/// Smoothed interval between two frames' arrivals, and the previous arrival: the
|
||||||
/// spine of the arrival-anchored clock (see `build_stamps`).
|
/// spine of the arrival-anchored clock (see `build_stamps`). In relative mode no frame
|
||||||
|
/// reaches that branch, so `on_data` keeps the same pair up to date itself -- the
|
||||||
|
/// reservation's plausibility test below needs a measured cadence whatever clock the
|
||||||
|
/// user chose, and the two writers are mutually exclusive by mode.
|
||||||
double arrival_interval_ = 0.0;
|
double arrival_interval_ = 0.0;
|
||||||
double arrival_prev_ = 0.0;
|
double arrival_prev_ = 0.0;
|
||||||
bool have_arrival_interval_ = false;
|
bool have_arrival_interval_ = false;
|
||||||
|
/// The previous *accepted* frame's receive time, for the one measurement the reservation
|
||||||
|
/// test needs: how much wall time actually passed. Kept apart from `arrival_prev_` on
|
||||||
|
/// purpose -- that one is the arrival clock's own bookkeeping and is re-anchored by the
|
||||||
|
/// clock-choice code, while this is a bare witness of the wire and must never be moved by
|
||||||
|
/// a mode change.
|
||||||
|
double prev_frame_recv_ = 0.0;
|
||||||
|
bool have_prev_frame_recv_ = false;
|
||||||
/// The stream's time source, chosen once and sticky: 0 undecided, 1 the declared
|
/// The stream's time source, chosen once and sticky: 0 undecided, 1 the declared
|
||||||
/// time signal, 2 arrival. Re-evaluated only on a CONFIG. A per-frame choice
|
/// time signal, 2 arrival. Re-evaluated only on a CONFIG. A per-frame choice
|
||||||
/// interleaved two timelines in one ring -- some frames stamped from a declared
|
/// interleaved two timelines in one ring -- some frames stamped from a declared
|
||||||
/// signal carrying volt-like values, others from the arrival clock -- which is a
|
/// signal carrying volt-like values, others from the arrival clock -- which is a
|
||||||
/// time base no reader can make sense of (see `build_stamps`).
|
/// time base no reader can make sense of (see `build_stamps`).
|
||||||
int time_source_ = 0;
|
int time_source_ = 0;
|
||||||
/// Set when the source changed under the recorded history: the rings have to be
|
/// Relative-time mode's clock: seconds since the stream began, accumulated at the interval
|
||||||
/// emptied before more samples go in.
|
/// the plan declares. Advanced by `lost * batch_` samples' worth for every packet the counter
|
||||||
bool history_reset_pending_ = false;
|
/// reports missing (before the frame's own samples) and then by the frame's own `n`, so it is
|
||||||
|
/// exactly `(counter * batch + sample_index) * dt` for a constant batch and degrades
|
||||||
|
/// gracefully when a frame's length disagrees.
|
||||||
|
///
|
||||||
|
/// It is a **time**, not a position multiplied by the current plan's interval, and the
|
||||||
|
/// difference is the whole of one defect: a plan is rebuilt whenever the channel list changes
|
||||||
|
/// (a CONFIG, an enable, a session's math channel) and `Partition::base_dt` -- which is the
|
||||||
|
/// 1 kHz fallback until a *streamed* channel declares a rate -- can therefore change under a
|
||||||
|
/// running stream. With `position * dt` a plan that shortened the interval made every later
|
||||||
|
/// stamp *behind* the one already written, which the frame loop's monotonic clamp then held
|
||||||
|
/// for the rest of the run: the timeline stood still, the trigger's `history` never reached
|
||||||
|
/// its pre-window, and nothing was drawn (see `on_data`). Accumulated, a plan change is what
|
||||||
|
/// it is -- a slope change -- and the clock cannot go backwards.
|
||||||
|
double rel_base_s_ = 0.0;
|
||||||
|
/// Samples per DATA packet, fixed by the first frame. Only ever needed to size what is
|
||||||
|
/// *absent*: a delivered frame carries its own length. 0 until the first frame.
|
||||||
|
std::uint32_t batch_ = 0;
|
||||||
|
/// Latches the one warning about a frame whose length disagrees with the stream's first.
|
||||||
|
bool warned_batch_ = false;
|
||||||
|
/// The newest counter seen, and the hole's address. Tracked here rather than inferred from
|
||||||
|
/// `frame->lost` because a *late* frame's counter is behind the sequence and must not move
|
||||||
|
/// the mark forward.
|
||||||
|
std::uint32_t last_counter_ = 0;
|
||||||
|
bool have_counter_ = false;
|
||||||
|
/// Last absolute stamp written, epoch-relative, so a reserved hole can continue the
|
||||||
|
/// absolute clock at the declared cadence. The packets that would have carried the real
|
||||||
|
/// stamps never arrived, so a cadence continuation is the only honest value available.
|
||||||
|
double last_abs_stamp_ = 0.0;
|
||||||
|
/// Warn-once latch for a loss past the reservation cap. A member, not a function-local
|
||||||
|
/// `static`, matching the latch Task 2 added for the same reason.
|
||||||
|
bool warned_outage_ = false;
|
||||||
|
/// Warn-once latch for a reservation the arrival clock did not corroborate, or that the ring
|
||||||
|
/// could not hold (see `gap_plausible()`). Once per connection, like the two above.
|
||||||
|
bool warned_phantom_gap_ = false;
|
||||||
|
/// Warn-once latch for the one bridged packet whose time signal glitched.
|
||||||
|
bool warned_bridge_ = false;
|
||||||
|
/// Warn-once latch for the declared time signal failing for good, so the arrival clock is
|
||||||
|
/// taken instead.
|
||||||
|
bool warned_time_source_ = false;
|
||||||
|
/// Warn-once latch for a frame whose every stamp was held back by the frame loop's monotonic
|
||||||
|
/// clamp. The clamp exists for a stream clock that glitches; a *whole frame* behind the clock is
|
||||||
|
/// the timeline standing still, and while it lasts nothing is drawn and no trigger can latch.
|
||||||
|
bool warned_clock_stall_ = false;
|
||||||
|
/// Whether this connection has adopted its clock origin (`ScopeModel::set_epoch_if_unset()`).
|
||||||
|
/// A state and not `epoch() == 0.0`, which re-fires in relative mode -- where the first frame's
|
||||||
|
/// stamp *is* zero -- and moves the origin under the timeline. Per connection, like every other
|
||||||
|
/// piece of session state here: a new session's clock is a new clock.
|
||||||
|
bool epoch_adopted_ = false;
|
||||||
|
/// The re-plan generation this object last reacted to. The buffer is re-planned whenever the
|
||||||
|
/// model rebuilds it, and the state tied to the old store (`tick_` and the accumulators) is
|
||||||
|
/// reset when this falls behind `ScopeModel::plan_generation()`.
|
||||||
|
unsigned seen_plan_generation_ = 0;
|
||||||
std::vector<double> stamps_;
|
std::vector<double> stamps_;
|
||||||
|
std::vector<double> abs_stamps_;
|
||||||
/// The decimation filter's state: a running sum per channel (and one for the shared
|
/// The decimation filter's state: a running sum per channel (and one for the shared
|
||||||
/// clock) over the group of base samples that becomes one stored sample. A boxcar
|
/// clock) over the group of base samples that becomes one stored sample. A boxcar
|
||||||
/// average, which is what turns decimation into a *resampling* rather than a stride:
|
/// average, which is what turns decimation into a *resampling* rather than a stride:
|
||||||
@@ -100,7 +237,17 @@ class Acquisition {
|
|||||||
/// once the slot's samples have all been pushed. One entry per channel, refilled every slot.
|
/// once the slot's samples have all been pushed. One entry per channel, refilled every slot.
|
||||||
std::vector<f32> math_sample_;
|
std::vector<f32> math_sample_;
|
||||||
std::vector<std::uint64_t> value_acc_n_;
|
std::vector<std::uint64_t> value_acc_n_;
|
||||||
|
/// Per channel, `time_stride * stride[i] - 1`: the group boundary as a **mask**, so the
|
||||||
|
/// innermost loop tests `(tick & mask) == mask` instead of `tick % period == period - 1`.
|
||||||
|
/// The divisor is a runtime value, so that modulo compiled to a real 64-bit `div` once per
|
||||||
|
/// channel per *wire* sample -- the filter reads every sample by design, so it was the
|
||||||
|
/// producer's largest avoidable cost. Both factors are powers of two (`plan_partition()`
|
||||||
|
/// doubles `time_stride` from 1, and every `stride[i]` is 1), which is what makes the mask
|
||||||
|
/// equivalent; it is sized and filled with the accumulators below, so a re-plan cannot
|
||||||
|
/// leave it addressing the previous partition's groups.
|
||||||
|
std::vector<std::uint64_t> acc_mask_;
|
||||||
double time_acc_ = 0.0;
|
double time_acc_ = 0.0;
|
||||||
|
double abs_time_acc_ = 0.0;
|
||||||
std::uint64_t time_acc_n_ = 0;
|
std::uint64_t time_acc_n_ = 0;
|
||||||
/// The partition the accumulators were sized for, so a re-plan resets them rather than
|
/// The partition the accumulators were sized for, so a re-plan resets them rather than
|
||||||
/// averaging across the boundary.
|
/// averaging across the boundary.
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "capture.hpp"
|
||||||
|
|
||||||
|
#include <fcntl.h>
|
||||||
|
#include <sys/mman.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <ctime>
|
||||||
|
|
||||||
|
#include <chrono>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// The file header is one 64-byte block: fixed fields first, zeroed padding after.
|
||||||
|
constexpr std::uint64_t kHeaderBytes = 64;
|
||||||
|
/// `seq` lives at the *end* of a slot, at a fixed offset, because the reader must
|
||||||
|
/// find it without trusting any other field.
|
||||||
|
constexpr std::uint64_t kSeqBytes = 8;
|
||||||
|
|
||||||
|
void put_u32(std::uint8_t* p, std::uint32_t v) { std::memcpy(p, &v, 4); }
|
||||||
|
void put_u16(std::uint8_t* p, std::uint16_t v) { std::memcpy(p, &v, 2); }
|
||||||
|
void put_f64(std::uint8_t* p, double v) { std::memcpy(p, &v, 8); }
|
||||||
|
std::uint32_t get_u32(const std::uint8_t* p) {
|
||||||
|
std::uint32_t v;
|
||||||
|
std::memcpy(&v, p, 4);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
double get_f64(const std::uint8_t* p) {
|
||||||
|
double v;
|
||||||
|
std::memcpy(&v, p, 8);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
double wall_seconds() {
|
||||||
|
struct timespec ts;
|
||||||
|
clock_gettime(CLOCK_REALTIME, &ts);
|
||||||
|
return double(ts.tv_sec) + double(ts.tv_nsec) * 1e-9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `seq` is published with a release store *after* the bytes are down, so a reader
|
||||||
|
/// that sees a non-zero `seq` sees the whole slot -- the reason no `fsync` is needed
|
||||||
|
/// for the file to be readable after a crash. Plain __atomic builtins, not
|
||||||
|
/// `std::atomic_ref`: the target is mmap'd memory the class does not own as an object.
|
||||||
|
void publish_seq(std::uint8_t* slot, std::uint64_t seq) {
|
||||||
|
__atomic_store_n(reinterpret_cast<std::uint64_t*>(slot + kSlotBytes - kSeqBytes), seq,
|
||||||
|
__ATOMIC_RELEASE);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint64_t read_seq(const std::uint8_t* slot) {
|
||||||
|
return __atomic_load_n(reinterpret_cast<const std::uint64_t*>(slot + kSlotBytes - kSeqBytes),
|
||||||
|
__ATOMIC_ACQUIRE);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void PacketCapture::set_error(const char* what) noexcept {
|
||||||
|
std::snprintf(error_, sizeof(error_), "%s", what);
|
||||||
|
error_set_.store(true, std::memory_order_release);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PacketCapture::error(char* out, std::size_t cap) const noexcept {
|
||||||
|
if (cap == 0) return;
|
||||||
|
out[0] = '\0';
|
||||||
|
if (!error_set_.load(std::memory_order_acquire)) return;
|
||||||
|
std::snprintf(out, cap, "%s", error_);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PacketCapture::open(const std::string& path, std::uint32_t slot_count) {
|
||||||
|
close();
|
||||||
|
path_ = path;
|
||||||
|
slot_count_ = slot_count;
|
||||||
|
seq_ = 0;
|
||||||
|
cursor_ = 0;
|
||||||
|
stored_.store(0, std::memory_order_relaxed);
|
||||||
|
truncated_.store(0, std::memory_order_relaxed);
|
||||||
|
error_set_.store(false, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
const std::chrono::steady_clock::time_point t0 = std::chrono::steady_clock::now();
|
||||||
|
|
||||||
|
// Keep the previous capture one deep: re-enabling must not destroy the capture the
|
||||||
|
// user enabled *for*. A failure here is reported, and the old previous kept.
|
||||||
|
std::string prev = path;
|
||||||
|
const std::size_t slash = prev.rfind('/');
|
||||||
|
const std::size_t dot = prev.rfind('.');
|
||||||
|
if (dot != std::string::npos && (slash == std::string::npos || dot > slash)) {
|
||||||
|
prev.insert(dot, "-prev");
|
||||||
|
} else {
|
||||||
|
prev += "-prev";
|
||||||
|
}
|
||||||
|
::rename(path.c_str(), prev.c_str());
|
||||||
|
|
||||||
|
fd_ = ::open(path.c_str(), O_RDWR | O_CREAT | O_EXCL | O_CLOEXEC, 0600);
|
||||||
|
if (fd_ < 0) {
|
||||||
|
set_error(strerror(errno));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reserved, not sparse: a store into a page the filesystem cannot back raises
|
||||||
|
// SIGBUS and kills udpscope outright, and an out-of-space condition must be an
|
||||||
|
// error at the moment the user clicked, not a crash ten minutes later.
|
||||||
|
const std::uint64_t total = kHeaderBytes + std::uint64_t(slot_count) * kSlotBytes;
|
||||||
|
if (::posix_fallocate(fd_, 0, off_t(total)) != 0) {
|
||||||
|
set_error(strerror(errno));
|
||||||
|
::close(fd_);
|
||||||
|
fd_ = -1;
|
||||||
|
::unlink(path.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
map_ = static_cast<std::uint8_t*>(
|
||||||
|
::mmap(nullptr, total, PROT_READ | PROT_WRITE, MAP_SHARED, fd_, 0));
|
||||||
|
if (map_ == MAP_FAILED) {
|
||||||
|
map_ = nullptr;
|
||||||
|
set_error(strerror(errno));
|
||||||
|
::close(fd_);
|
||||||
|
fd_ = -1;
|
||||||
|
::unlink(path.c_str());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
map_bytes_ = total;
|
||||||
|
|
||||||
|
std::uint8_t* h = map_;
|
||||||
|
std::memset(h, 0, kHeaderBytes);
|
||||||
|
std::memcpy(h, "UDPSCAP\0", 8);
|
||||||
|
put_u32(h + 8, 1); // version
|
||||||
|
put_u32(h + 12, slot_count);
|
||||||
|
put_u32(h + 16, kSlotBytes);
|
||||||
|
put_u32(h + 20, 0); // cursor: a hint, `seq` is the order
|
||||||
|
put_f64(h + 24, wall_seconds()); // the epoch recv_time is relative to
|
||||||
|
put_u32(h + 32, peer_addr_);
|
||||||
|
put_u16(h + 36, peer_port_);
|
||||||
|
put_u16(h + 38, 0); // local_port: the client does not expose its own socket
|
||||||
|
const double ms =
|
||||||
|
std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
||||||
|
open_ms_.store(ms, std::memory_order_relaxed);
|
||||||
|
|
||||||
|
enabled_.store(true, std::memory_order_release);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PacketCapture::close() noexcept {
|
||||||
|
if (map_ != nullptr) {
|
||||||
|
::munmap(map_, map_bytes_);
|
||||||
|
map_ = nullptr;
|
||||||
|
}
|
||||||
|
if (fd_ >= 0) {
|
||||||
|
::close(fd_);
|
||||||
|
fd_ = -1;
|
||||||
|
}
|
||||||
|
map_bytes_ = 0;
|
||||||
|
enabled_.store(false, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void PacketCapture::store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept {
|
||||||
|
if (!enabled_.load(std::memory_order_relaxed) || map_ == nullptr) return;
|
||||||
|
|
||||||
|
std::uint8_t* slot = map_ + kHeaderBytes + std::uint64_t(cursor_) * kSlotBytes;
|
||||||
|
std::uint32_t orig = std::uint32_t(len);
|
||||||
|
std::size_t fit = len;
|
||||||
|
if (fit > kMaxDatagramBytes) {
|
||||||
|
fit = kMaxDatagramBytes;
|
||||||
|
truncated_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
put_f64(slot, recv_time);
|
||||||
|
put_u32(slot + 8, std::uint32_t(fit));
|
||||||
|
put_u32(slot + 12, orig);
|
||||||
|
std::memcpy(slot + kRecordHeaderBytes, bytes, fit);
|
||||||
|
++seq_;
|
||||||
|
publish_seq(slot, seq_); // last: a non-zero `seq` is what makes a slot complete
|
||||||
|
|
||||||
|
cursor_ = (cursor_ + 1) % slot_count_;
|
||||||
|
put_u32(map_ + 20, cursor_); // a hint for a human in xxd; `seq` is the order
|
||||||
|
stored_.fetch_add(1, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool read_capture(const std::string& path, CaptureReadStats* out) {
|
||||||
|
if (out == nullptr) return false;
|
||||||
|
*out = CaptureReadStats{};
|
||||||
|
|
||||||
|
FILE* f = std::fopen(path.c_str(), "rb");
|
||||||
|
if (f == nullptr) return false;
|
||||||
|
std::fseek(f, 0, SEEK_END);
|
||||||
|
const long size = std::ftell(f);
|
||||||
|
std::fseek(f, 0, SEEK_SET);
|
||||||
|
out->file_bytes = std::uint64_t(size < 0 ? 0 : size);
|
||||||
|
|
||||||
|
std::uint8_t h[kHeaderBytes];
|
||||||
|
if (std::fread(h, 1, sizeof(h), f) != sizeof(h)) {
|
||||||
|
std::fclose(f);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
out->header_ok = std::memcmp(h, "UDPSCAP\0", 8) == 0 && get_u32(h + 8) == 1;
|
||||||
|
out->slot_count = get_u32(h + 12);
|
||||||
|
out->slot_bytes = get_u32(h + 16);
|
||||||
|
out->cursor = get_u32(h + 20);
|
||||||
|
out->t0_realtime = get_f64(h + 24);
|
||||||
|
if (!out->header_ok || out->slot_count == 0 || out->slot_bytes < kRecordHeaderBytes + 8) {
|
||||||
|
std::fclose(f);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::uint32_t payload = out->slot_bytes - kRecordHeaderBytes - kSeqBytes;
|
||||||
|
std::vector<std::uint8_t> slot(out->slot_bytes);
|
||||||
|
for (std::uint32_t i = 0; i < out->slot_count; ++i) {
|
||||||
|
if (std::fseek(f, long(kHeaderBytes + std::uint64_t(i) * out->slot_bytes), SEEK_SET) != 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (std::fread(slot.data(), 1, slot.size(), f) != slot.size()) break;
|
||||||
|
const std::uint64_t seq = read_seq(slot.data());
|
||||||
|
const std::uint32_t len = get_u32(slot.data() + 8);
|
||||||
|
const std::uint32_t orig = get_u32(slot.data() + 12);
|
||||||
|
if (seq == 0 && len == 0 && orig == 0) {
|
||||||
|
++out->empty;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (seq == 0) {
|
||||||
|
++out->torn; // bytes down, ordinal never published: a capture that died mid-copy
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
++out->stored;
|
||||||
|
out->payload += len;
|
||||||
|
if (orig > payload) ++out->truncated;
|
||||||
|
if (out->first_seq == 0 || seq < out->first_seq) out->first_seq = seq;
|
||||||
|
if (seq > out->last_seq) out->last_seq = seq;
|
||||||
|
}
|
||||||
|
std::fclose(f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// The largest datagram the client's socket can deliver, and therefore the payload
|
||||||
|
/// capacity of one capture slot. Kept equal to the client's own receive buffer: an
|
||||||
|
/// over-long datagram is impossible by construction, and `store()` counts the
|
||||||
|
/// impossibility rather than trusting it.
|
||||||
|
inline constexpr std::uint32_t kMaxDatagramBytes = 65535 + 17;
|
||||||
|
|
||||||
|
/// One slot's stride: the fixed record header, the largest datagram, and the `seq`
|
||||||
|
/// word that is written last. 8-byte aligned.
|
||||||
|
inline constexpr std::uint32_t kRecordHeaderBytes = 16; ///< t_arrival f64, len u32, orig_len u32
|
||||||
|
inline constexpr std::uint32_t kSlotBytes =
|
||||||
|
kRecordHeaderBytes + kMaxDatagramBytes + 8; ///< the trailing 8 are `seq`
|
||||||
|
|
||||||
|
/// Owns the ring file a packet capture stores raw datagrams into, and stores a
|
||||||
|
/// datagram into it. It includes no GTK, no `ScopeModel`, and no client types: its
|
||||||
|
/// whole world is bytes, a clock reading and one file.
|
||||||
|
///
|
||||||
|
/// Everything here runs on the producer thread except `open()`'s caller choice and
|
||||||
|
/// the GTK thread's reads of the counters and the error, which are publishable
|
||||||
|
/// without a lock: see the field table in the design note
|
||||||
|
/// (`docs/superpowers/specs/2026-10-09-udp-packet-capture-design.md`).
|
||||||
|
class PacketCapture {
|
||||||
|
public:
|
||||||
|
PacketCapture() = default;
|
||||||
|
~PacketCapture() { close(); }
|
||||||
|
|
||||||
|
PacketCapture(const PacketCapture&) = delete;
|
||||||
|
PacketCapture& operator=(const PacketCapture&) = delete;
|
||||||
|
|
||||||
|
/// Renames an existing capture to `<path>` minus extension plus `-prev.bin`,
|
||||||
|
/// creates and reserves the new file, maps it, writes the header. Returns false
|
||||||
|
/// and leaves the object disabled on any failure, with `error()` set.
|
||||||
|
bool open(const std::string& path, std::uint32_t slot_count);
|
||||||
|
|
||||||
|
/// Unmaps and closes. Idempotent; keeps the counters, which read "since open()".
|
||||||
|
void close() noexcept;
|
||||||
|
|
||||||
|
bool enabled() const noexcept { return enabled_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
/// One datagram. Called only from the producer thread. Never allocates, never
|
||||||
|
/// blocks, never fails: an over-long datagram is truncated to the slot and counted.
|
||||||
|
void store(const std::uint8_t* bytes, std::size_t len, double recv_time) noexcept;
|
||||||
|
|
||||||
|
std::uint64_t stored() const noexcept { return stored_.load(std::memory_order_relaxed); }
|
||||||
|
std::uint64_t truncated() const noexcept {
|
||||||
|
return truncated_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
std::uint32_t slot_count() const noexcept { return slot_count_; }
|
||||||
|
const std::string& path() const noexcept { return path_; }
|
||||||
|
|
||||||
|
/// The source's IPv4, in network order, and its port: written into the header at
|
||||||
|
/// open() so the pcap converter can synthesise a UDP header. Call before open(),
|
||||||
|
/// from the thread that owns the decision -- the same write-once discipline as the
|
||||||
|
/// path. `local_port` stays zero: the client does not expose its own socket.
|
||||||
|
void set_peer(std::uint32_t addr_net_order, std::uint16_t port) noexcept {
|
||||||
|
peer_addr_ = addr_net_order;
|
||||||
|
peer_port_ = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wall milliseconds `open()` took, measured rather than asserted: the one cost a
|
||||||
|
/// toggle has, and a reading above ~50 ms is a finding about the filesystem.
|
||||||
|
double open_ms() const noexcept { return open_ms_.load(std::memory_order_relaxed); }
|
||||||
|
|
||||||
|
/// Copies the last open() failure out, or writes an empty string. Deliberately not
|
||||||
|
/// a `const std::string&`: the producer writes the fixed buffer and *then* publishes
|
||||||
|
/// the flag with a release store, and the GTK thread copies only when the flag reads
|
||||||
|
/// true -- a `std::string` shared like that is a data race with a reallocating
|
||||||
|
/// buffer, the kind that reads correctly for months.
|
||||||
|
void error(char* out, std::size_t cap) const noexcept;
|
||||||
|
|
||||||
|
private:
|
||||||
|
void set_error(const char* what) noexcept;
|
||||||
|
|
||||||
|
std::string path_;
|
||||||
|
std::uint32_t slot_count_ = 0;
|
||||||
|
int fd_ = -1;
|
||||||
|
std::uint8_t* map_ = nullptr; ///< whole file, header first
|
||||||
|
std::uint64_t map_bytes_ = 0;
|
||||||
|
std::uint64_t seq_ = 0; ///< 1-based ordinal since open(); producer-only
|
||||||
|
std::uint32_t cursor_ = 0; ///< next slot index to write; producer-only
|
||||||
|
std::uint32_t peer_addr_ = 0; ///< the source's IPv4, network order
|
||||||
|
std::uint16_t peer_port_ = 0;
|
||||||
|
std::atomic<bool> enabled_{false};
|
||||||
|
std::atomic<std::uint64_t> stored_{0};
|
||||||
|
std::atomic<std::uint64_t> truncated_{0};
|
||||||
|
std::atomic<bool> error_set_{false};
|
||||||
|
std::atomic<double> open_ms_{0.0};
|
||||||
|
mutable char error_[128] = {0};
|
||||||
|
};
|
||||||
|
|
||||||
|
/// What a capture file says when read back off disk. `stored` counts slots with a
|
||||||
|
/// non-zero `seq`; `torn` counts slots with bytes but no `seq` (a capture that died
|
||||||
|
/// mid-copy); `empty` counts all-zero slots, which a freshly reserved file is full of.
|
||||||
|
struct CaptureReadStats {
|
||||||
|
bool header_ok = false;
|
||||||
|
std::uint32_t slot_count = 0;
|
||||||
|
std::uint32_t slot_bytes = 0;
|
||||||
|
std::uint32_t cursor = 0;
|
||||||
|
double t0_realtime = 0.0;
|
||||||
|
std::uint64_t stored = 0;
|
||||||
|
std::uint64_t torn = 0;
|
||||||
|
std::uint64_t empty = 0;
|
||||||
|
std::uint64_t truncated = 0; ///< slots whose `orig_len` exceeds the payload capacity
|
||||||
|
std::uint64_t payload = 0; ///< sum of stored slots' `len`: the bytes the wire delivered
|
||||||
|
std::uint64_t first_seq = 0;
|
||||||
|
std::uint64_t last_seq = 0;
|
||||||
|
std::uint64_t file_bytes = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Reads a capture file back and validates it. Pure POSIX plus `<cstdio>`; used by the
|
||||||
|
/// headless probe, and `tools/capture_to_pcap.py` re-implements the walk in Python.
|
||||||
|
bool read_capture(const std::string& path, CaptureReadStats* out);
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -31,10 +31,9 @@ ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>>
|
|||||||
|
|
||||||
void ChannelBuffer::acquire() noexcept {
|
void ChannelBuffer::acquire() noexcept {
|
||||||
const u64 produced = values_->produced();
|
const u64 produced = values_->produced();
|
||||||
if (produced <= published_) {
|
// Nothing new skips the append pass only: the backfill drain at the end of this function
|
||||||
// Nothing new; still allow the window to slide if another reader dropped it.
|
// runs either way, because a late packet can land in a frame interval that pushed nothing.
|
||||||
return;
|
if (produced > published_) {
|
||||||
}
|
|
||||||
// A producer that lapped this ring -- a long trigger wait, or a store that was
|
// A producer that lapped this ring -- a long trigger wait, or a store that was
|
||||||
// not being published for a while -- makes the incremental append meaningless:
|
// not being published for a while -- makes the incremental append meaningless:
|
||||||
// the samples between `published_` and `produced_` are no longer in the ring.
|
// the samples between `published_` and `produced_` are no longer in the ring.
|
||||||
@@ -60,12 +59,35 @@ void ChannelBuffer::acquire() noexcept {
|
|||||||
// window is pinned by the line above, so its origin is a fixed sample, and this is the one
|
// window is pinned by the line above, so its origin is a fixed sample, and this is the one
|
||||||
// place in the object's life where that sample's slot is safe to read.
|
// place in the object's life where that sample's slot is safe to read.
|
||||||
if (pyramid_.size() > 0) window_x0_ = time_of(absolute(0));
|
if (pyramid_.size() > 0) window_x0_ = time_of(absolute(0));
|
||||||
|
}
|
||||||
|
// Backfills, applied after the append pass above: an amend is only ever queued for a slot
|
||||||
|
// the value ring already produced, so the append has reached it and the pyramid's bounds
|
||||||
|
// test will accept it. Unconditional, outside the `produced > published_` guard -- a late
|
||||||
|
// packet can land in a frame interval that pushed nothing new, and the plan's placement
|
||||||
|
// behind the old early return would have left the pyramid stale until the next push.
|
||||||
|
if (!pending_amends_.empty()) {
|
||||||
|
for (const Amend& a : pending_amends_) pyramid_.amend(a.absolute, a.value);
|
||||||
|
pending_amends_.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChannelBuffer::invalidate() noexcept {
|
void ChannelBuffer::invalidate() noexcept {
|
||||||
published_ = 0;
|
published_ = 0;
|
||||||
window_x0_ = 0.0;
|
window_x0_ = 0.0;
|
||||||
pyramid_.clear();
|
pyramid_.clear();
|
||||||
|
pending_amends_.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
void ChannelBuffer::republish_from(u64 absolute) noexcept {
|
||||||
|
published_ = absolute;
|
||||||
|
window_x0_ = 0.0;
|
||||||
|
pending_amends_.clear();
|
||||||
|
// `clear()` leaves the pyramid empty at absolute 0, and `drop_before()` past the end
|
||||||
|
// restarts an empty window at the index it is given -- which is the only way to say
|
||||||
|
// "nothing is published, and the next append is sample `absolute`". The no-op guard is
|
||||||
|
// for `absolute == 0`, where `clear()` has already said exactly that.
|
||||||
|
pyramid_.clear();
|
||||||
|
if (absolute > 0) pyramid_.drop_before(absolute);
|
||||||
}
|
}
|
||||||
|
|
||||||
f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||||
@@ -189,7 +211,7 @@ void ChannelBuffer::raw(u64 begin, u64 end, f32* out) const {
|
|||||||
for (u64 i = count; i < end - begin; ++i) out[i] = 0.0f;
|
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 {
|
plot::data::GatherMode mode) const {
|
||||||
const u64 n = pyramid_.size();
|
const u64 n = pyramid_.size();
|
||||||
const u64 b = std::min(begin, n);
|
const u64 b = std::min(begin, n);
|
||||||
@@ -197,7 +219,7 @@ void ChannelBuffer::gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_
|
|||||||
if (b >= e) return;
|
if (b >= e) return;
|
||||||
|
|
||||||
const u64 origin = pyramid_.window_base();
|
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) {
|
const auto emit_raw = [&](u64 from, u64 to) {
|
||||||
for (u64 i = from; i < to; ++i) {
|
for (u64 i = from; i < to; ++i) {
|
||||||
@@ -302,13 +324,13 @@ u64 SlotSeries::snap_index(f64 x) const {
|
|||||||
return buffer_ != nullptr ? buffer_->snap_index(x) : 0;
|
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 {
|
plot::data::GatherMode mode) const {
|
||||||
if (event_ != nullptr) {
|
if (event_ != nullptr) {
|
||||||
event_->gather(level, begin, end, out_xy, mode);
|
event_->gather(level, begin, end, x_origin, out_xy, mode);
|
||||||
return;
|
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 {
|
void SlotSeries::copy_raw(u64 begin, u64 end, f32* out) const {
|
||||||
|
|||||||
@@ -48,6 +48,27 @@ class ChannelBuffer final : public plot::data::Series {
|
|||||||
void acquire() noexcept;
|
void acquire() noexcept;
|
||||||
/// Consumer thread: forgets the published window (after a rebind or a reset).
|
/// Consumer thread: forgets the published window (after a rebind or a reset).
|
||||||
void invalidate() noexcept;
|
void invalidate() noexcept;
|
||||||
|
/// Consumer thread: drops the published window and re-publishes it from absolute sample
|
||||||
|
/// `absolute` on the next `acquire()`.
|
||||||
|
///
|
||||||
|
/// For a ring whose contents were *rewritten in place* rather than appended to -- a math
|
||||||
|
/// channel recomputed over its inputs' retained window. `acquire()` is incremental, so
|
||||||
|
/// a rewrite that leaves `produced()` where it was publishes nothing and the pyramid goes
|
||||||
|
/// on describing the samples the edit replaced. `invalidate()` would do it too, but it
|
||||||
|
/// restarts at 0 and `acquire()` would then walk every absolute index ever produced; this
|
||||||
|
/// restarts at the oldest index the ring still holds, so the rebuild is one window.
|
||||||
|
void republish_from(u64 absolute) noexcept;
|
||||||
|
|
||||||
|
/// Producer thread, under `ScopeModel::mutex()`: records that absolute sample `absolute`
|
||||||
|
/// has been backfilled, for `acquire()` to fold into the pyramid.
|
||||||
|
///
|
||||||
|
/// The pyramid is mutated *only* on the consumer thread, because a bucket is a 32-byte
|
||||||
|
/// non-atomic aggregate the renderer reads unlocked at paint time: amending it from the
|
||||||
|
/// producer would let a reader see a half-updated bucket whose `sum` did not match its
|
||||||
|
/// `count`, which is a wrong mean out of a structure whose readers trust `count == 0` to
|
||||||
|
/// mean absent. The value ring is amended by the producer, which owns its writes; the
|
||||||
|
/// pyramid waits for here.
|
||||||
|
void queue_amend(u64 absolute, f32 v) { pending_amends_.push_back({absolute, v}); }
|
||||||
|
|
||||||
void set_nominal_dt(f64 dt) noexcept { dt_ = dt; }
|
void set_nominal_dt(f64 dt) noexcept { dt_ = dt; }
|
||||||
/// Re-bases the x axis: `x0()`/`x_at()` become `epoch + timestamp`. Used to make
|
/// Re-bases the x axis: `x0()`/`x_at()` become `epoch + timestamp`. Used to make
|
||||||
@@ -73,16 +94,18 @@ class ChannelBuffer final : public plot::data::Series {
|
|||||||
/// datagram leaves a gap, so index times must come from the time ring.
|
/// datagram leaves a gap, so index times must come from the time ring.
|
||||||
f64 x_at(u64 i) const override;
|
f64 x_at(u64 i) const override;
|
||||||
/// Bracketing samples by binary search over the time ring, which is monotonic,
|
/// Bracketing samples by binary search over the time ring, which is monotonic,
|
||||||
/// so a crosshair value is interpolated between the two samples actually either
|
/// so a crosshair reads whichever of the two samples actually either side of the
|
||||||
/// side of the pointer -- a grid walk would land on the wrong pair after a gap.
|
/// 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;
|
bool neighbours(f64 x, u64& i0, u64& i1) const override;
|
||||||
u64 snap_index(f64 x) const override;
|
u64 snap_index(f64 x) const override;
|
||||||
plot::Range x_bounds() const override;
|
plot::Range x_bounds() const override;
|
||||||
plot::Range y_bounds() const override;
|
plot::Range y_bounds() const override;
|
||||||
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
const plot::data::MinMaxPyramid& pyramid() const override { return pyramid_; }
|
||||||
/// x is emitted **relative to `x0()`** (the library's contract): staging an
|
/// 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.
|
/// absolute epoch time as f32 would quantise it far coarser than a pixel, and
|
||||||
void gather(u32 level, u64 begin, u64 end, std::vector<f32>& out_xy,
|
/// 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;
|
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
||||||
|
|
||||||
/// `copy_raw`, published so the retargetable trace series can forward the
|
/// `copy_raw`, published so the retargetable trace series can forward the
|
||||||
@@ -98,6 +121,13 @@ class ChannelBuffer final : public plot::data::Series {
|
|||||||
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
|
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
|
||||||
f64 time_of(u64 absolute_index) const noexcept;
|
f64 time_of(u64 absolute_index) const noexcept;
|
||||||
|
|
||||||
|
struct Amend {
|
||||||
|
u64 absolute;
|
||||||
|
f32 value;
|
||||||
|
};
|
||||||
|
/// Backfills the producer has written into the value ring and the pyramid has not seen yet.
|
||||||
|
std::vector<Amend> pending_amends_;
|
||||||
|
|
||||||
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
|
std::shared_ptr<const plot::data::RingBuffer<f32>> values_;
|
||||||
std::shared_ptr<const plot::data::RingBuffer<f64>> times_;
|
std::shared_ptr<const plot::data::RingBuffer<f64>> times_;
|
||||||
u64 stride_ = 1;
|
u64 stride_ = 1;
|
||||||
@@ -109,8 +139,9 @@ class ChannelBuffer final : public plot::data::Series {
|
|||||||
/// slot cannot be overwritten under the read. Everything else on this object is read at
|
/// 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
|
/// *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
|
/// 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 producer lapped it between the bounds check and the copy -- and `x0()` is what
|
||||||
/// the renderer reconstructs every trace point from (`gather[i] + series.x0()`), so one such
|
/// `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
|
/// 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
|
/// 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
|
/// the "live plot flickers as if there were no data" defect. The cached value is also the
|
||||||
@@ -163,7 +194,7 @@ class SlotSeries final : public plot::data::Series {
|
|||||||
plot::Range x_bounds() const override;
|
plot::Range x_bounds() const override;
|
||||||
plot::Range y_bounds() const override;
|
plot::Range y_bounds() const override;
|
||||||
const plot::data::MinMaxPyramid& pyramid() 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;
|
plot::data::GatherMode mode = plot::data::GatherMode::Envelope) const override;
|
||||||
bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; }
|
bool streaming() const noexcept override { return event_ == nullptr && buffer_ != nullptr; }
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "condition.hpp"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
void EventDetector::configure(const EventCondition& c, double dt) noexcept {
|
||||||
|
if (c == cond_ && dt == dt_) return;
|
||||||
|
cond_ = c;
|
||||||
|
dt_ = dt;
|
||||||
|
// a = 1 - exp(-2*pi*fc*dt), clamped just under Nyquist so a never exceeds 1.
|
||||||
|
// fc == 0 leaves a == 1, which is a pass-through.
|
||||||
|
pole_a_ = 1.0;
|
||||||
|
if (cond_.hf_fc > 0.0 && dt > 0.0) {
|
||||||
|
const double nyquist = 0.5 / dt;
|
||||||
|
const double fc = cond_.hf_fc < nyquist * 0.99 ? cond_.hf_fc : nyquist * 0.99;
|
||||||
|
pole_a_ = 1.0 - std::exp(-2.0 * 3.14159265358979323846 * fc * dt);
|
||||||
|
}
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void EventDetector::reset() noexcept {
|
||||||
|
have_prev_ = false;
|
||||||
|
prev_ = 0.0;
|
||||||
|
in_pulse_ = false;
|
||||||
|
pulse_up_ = false;
|
||||||
|
pulse_start_s_ = 0.0;
|
||||||
|
inside_ = false;
|
||||||
|
last_cross_s_ = 0.0;
|
||||||
|
dropped_ = false;
|
||||||
|
pole_y_ = 0.0;
|
||||||
|
pole_have_ = false;
|
||||||
|
side_ = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
double EventDetector::filter(double v) noexcept {
|
||||||
|
// A NaN must stay a NaN and must not poison the state: a dropout is a real
|
||||||
|
// reading here, not an error.
|
||||||
|
if (!std::isfinite(v)) return v;
|
||||||
|
if (!pole_have_) {
|
||||||
|
pole_y_ = v;
|
||||||
|
pole_have_ = true;
|
||||||
|
return pole_y_;
|
||||||
|
}
|
||||||
|
pole_y_ += pole_a_ * (v - pole_y_);
|
||||||
|
return pole_y_;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EventDetector::decide(double v, bool& up, bool& down) noexcept {
|
||||||
|
up = false;
|
||||||
|
down = false;
|
||||||
|
if (cond_.noise_band <= 0.0) {
|
||||||
|
up = crossed_up(v);
|
||||||
|
down = crossed_down(v);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A Schmitt trigger: a sample inside the band decides nothing, and a crossing
|
||||||
|
// is a change between two *decided* sides. Seeding from the undecided side
|
||||||
|
// would fire on a signal that starts inside the band and leaves it without
|
||||||
|
// ever reaching `level`.
|
||||||
|
const int was = side_;
|
||||||
|
if (v >= cond_.level + cond_.noise_band) side_ = 1;
|
||||||
|
else if (v <= cond_.level - cond_.noise_band) side_ = -1;
|
||||||
|
up = was == -1 && side_ == 1;
|
||||||
|
down = was == 1 && side_ == -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
double EventDetector::feed(double value, double t) noexcept {
|
||||||
|
value = filter(value);
|
||||||
|
if (!have_prev_) {
|
||||||
|
prev_ = value;
|
||||||
|
have_prev_ = true;
|
||||||
|
// The window condition needs a side to change *from*, and the dropout a last-seen time
|
||||||
|
// to count from; neither can be derived from a crossing that has not happened. Seeding
|
||||||
|
// them here is what lets both handlers run without a "have we started" guard.
|
||||||
|
const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
|
||||||
|
const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
|
||||||
|
inside_ = value >= lo && value <= hi;
|
||||||
|
last_cross_s_ = t;
|
||||||
|
dropped_ = false;
|
||||||
|
return kNoEvent;
|
||||||
|
}
|
||||||
|
double event = kNoEvent;
|
||||||
|
switch (cond_.type) {
|
||||||
|
case TriggerType::Edge:
|
||||||
|
event = feed_edge(value, t);
|
||||||
|
break;
|
||||||
|
case TriggerType::PulseWidth:
|
||||||
|
event = feed_pulse(value, t);
|
||||||
|
break;
|
||||||
|
case TriggerType::Window:
|
||||||
|
event = feed_window(value, t);
|
||||||
|
break;
|
||||||
|
case TriggerType::Dropout:
|
||||||
|
event = feed_dropout(value, t);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// After the handler: every crossing test above reads `prev_` as the previous sample.
|
||||||
|
prev_ = value;
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
double EventDetector::feed_edge(double value, double t) noexcept {
|
||||||
|
bool up = false;
|
||||||
|
bool down = false;
|
||||||
|
decide(value, up, down);
|
||||||
|
const bool fired = (cond_.edge == TriggerEdge::Rising && up) ||
|
||||||
|
(cond_.edge == TriggerEdge::Falling && down) ||
|
||||||
|
(cond_.edge == TriggerEdge::Both && (up || down));
|
||||||
|
return fired ? t : kNoEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool EventDetector::qualifies(double width_s) const noexcept {
|
||||||
|
const double a = cond_.width_s < cond_.width2_s ? cond_.width_s : cond_.width2_s;
|
||||||
|
const double b = cond_.width_s < cond_.width2_s ? cond_.width2_s : cond_.width_s;
|
||||||
|
switch (cond_.cmp) {
|
||||||
|
case WidthCmp::Less:
|
||||||
|
return width_s < cond_.width_s;
|
||||||
|
case WidthCmp::Greater:
|
||||||
|
return width_s > cond_.width_s;
|
||||||
|
case WidthCmp::Within:
|
||||||
|
return width_s >= a && width_s <= b;
|
||||||
|
case WidthCmp::Outside:
|
||||||
|
return width_s < a || width_s > b;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
double EventDetector::feed_pulse(double value, double t) noexcept {
|
||||||
|
const bool up = crossed_up(value);
|
||||||
|
const bool down = crossed_down(value);
|
||||||
|
if (!in_pulse_) {
|
||||||
|
// Which crossing opens a pulse is what `edge` selects: Rising measures the time spent
|
||||||
|
// above the level, Falling the time below, Both every excursion either way.
|
||||||
|
const bool opens = (cond_.edge == TriggerEdge::Rising && up) ||
|
||||||
|
(cond_.edge == TriggerEdge::Falling && down) ||
|
||||||
|
(cond_.edge == TriggerEdge::Both && (up || down));
|
||||||
|
if (opens) {
|
||||||
|
in_pulse_ = true;
|
||||||
|
pulse_up_ = up;
|
||||||
|
pulse_start_s_ = t;
|
||||||
|
}
|
||||||
|
return kNoEvent;
|
||||||
|
}
|
||||||
|
const bool closes = pulse_up_ ? down : up;
|
||||||
|
if (!closes) return kNoEvent;
|
||||||
|
const double width = t - pulse_start_s_;
|
||||||
|
if (cond_.edge == TriggerEdge::Both) {
|
||||||
|
// The closing edge of one excursion is the opening edge of the next, so a square wave
|
||||||
|
// measures *every* half-period. Dropping the state here instead would measure every
|
||||||
|
// other one, and the hits would come out half as dense as the waveform.
|
||||||
|
pulse_up_ = !pulse_up_;
|
||||||
|
pulse_start_s_ = t;
|
||||||
|
} else {
|
||||||
|
in_pulse_ = false;
|
||||||
|
}
|
||||||
|
return qualifies(width) ? t : kNoEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
double EventDetector::feed_window(double value, double t) noexcept {
|
||||||
|
const double lo = cond_.level < cond_.level2 ? cond_.level : cond_.level2;
|
||||||
|
const double hi = cond_.level < cond_.level2 ? cond_.level2 : cond_.level;
|
||||||
|
// The band is applied on the edge the signal must *leave*, and clamped to just
|
||||||
|
// under half the window so `lo + band` can never pass `hi - band`.
|
||||||
|
double band = cond_.noise_band > 0.0 ? cond_.noise_band : 0.0;
|
||||||
|
const double room = 0.49 * (hi - lo);
|
||||||
|
if (band > room) band = room;
|
||||||
|
const bool inside = inside_ ? (value >= lo - band && value <= hi + band)
|
||||||
|
: (value >= lo + band && value <= hi - band);
|
||||||
|
if (inside == inside_) return kNoEvent;
|
||||||
|
inside_ = inside;
|
||||||
|
// The event is the *change of side*, so `Rising` reads as "into the band" and `Falling`
|
||||||
|
// as "out of it". A band of zero width is not a band: `inside` reduces to an exact
|
||||||
|
// float-equality test against the level, so the condition fires on none of an ordinary
|
||||||
|
// signal's samples and on every transition of one that sits exactly at the level -- a
|
||||||
|
// +/-1 square against a band of [1, 1] fired 19 times in a standalone check. Neither
|
||||||
|
// answer is a band, so the panel refuses one.
|
||||||
|
const bool fired = (cond_.edge == TriggerEdge::Rising && inside) ||
|
||||||
|
(cond_.edge == TriggerEdge::Falling && !inside) ||
|
||||||
|
cond_.edge == TriggerEdge::Both;
|
||||||
|
return fired ? t : kNoEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
double EventDetector::feed_dropout(double value, double t) noexcept {
|
||||||
|
const bool up = crossed_up(value);
|
||||||
|
const bool down = crossed_down(value);
|
||||||
|
const bool crossed = (cond_.edge == TriggerEdge::Rising && up) ||
|
||||||
|
(cond_.edge == TriggerEdge::Falling && down) ||
|
||||||
|
(cond_.edge == TriggerEdge::Both && (up || down));
|
||||||
|
if (crossed) {
|
||||||
|
last_cross_s_ = t;
|
||||||
|
dropped_ = false;
|
||||||
|
return kNoEvent;
|
||||||
|
}
|
||||||
|
// One event per silence, not one per sample of it: without the latch a dead signal would
|
||||||
|
// report an event on every sample after the timeout and fill the search's hit list with
|
||||||
|
// one answer repeated.
|
||||||
|
if (dropped_) return kNoEvent;
|
||||||
|
if (t - last_cross_s_ <= cond_.timeout_s) return kNoEvent;
|
||||||
|
dropped_ = true;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// Which way through a level counts. Shared by every condition type: for `Window` it is
|
||||||
|
/// the direction through the *band* (into it, out of it, or either), and for `Dropout` it
|
||||||
|
/// selects which crossings count as the signal still being alive.
|
||||||
|
///
|
||||||
|
/// It lives here rather than in `trigger.hpp` because the buffer search needs the
|
||||||
|
/// evaluator without the engine's arming state.
|
||||||
|
enum class TriggerEdge { Rising, Falling, Both };
|
||||||
|
|
||||||
|
/// What shape in the signal counts as an event.
|
||||||
|
enum class TriggerType { Edge, PulseWidth, Window, Dropout };
|
||||||
|
|
||||||
|
/// How a measured pulse width is compared with the condition's bounds. `Less` is the
|
||||||
|
/// glitch trigger: "a pulse shorter than it should be".
|
||||||
|
enum class WidthCmp { Less, Greater, Within, Outside };
|
||||||
|
|
||||||
|
/// Everything that decides whether a sample is an event. No arming, no capture window:
|
||||||
|
/// those belong to `TriggerConfig`.
|
||||||
|
struct EventCondition {
|
||||||
|
TriggerType type = TriggerType::Edge;
|
||||||
|
double level = 0.0;
|
||||||
|
double level2 = 0.0; ///< Window: the band's other edge. Ignored otherwise.
|
||||||
|
TriggerEdge edge = TriggerEdge::Rising;
|
||||||
|
WidthCmp cmp = WidthCmp::Greater; ///< PulseWidth only.
|
||||||
|
double width_s = 1.0e-3; ///< PulseWidth only.
|
||||||
|
double width2_s = 2.0e-3; ///< PulseWidth, Within/Outside only.
|
||||||
|
double timeout_s = 1.0e-3; ///< Dropout only.
|
||||||
|
/// Half-width of the amplitude band around `level` a sample must leave before
|
||||||
|
/// a crossing counts. 0 is the bare comparator. In signal units.
|
||||||
|
double noise_band = 0.0;
|
||||||
|
/// Corner of a one-pole low pass applied to the sample before any comparison.
|
||||||
|
/// 0 is off. In Hz.
|
||||||
|
double hf_fc = 0.0;
|
||||||
|
|
||||||
|
bool operator==(const EventCondition& o) const noexcept {
|
||||||
|
return type == o.type && level == o.level && level2 == o.level2 && edge == o.edge &&
|
||||||
|
cmp == o.cmp && width_s == o.width_s && width2_s == o.width2_s &&
|
||||||
|
timeout_s == o.timeout_s && noise_band == o.noise_band && hf_fc == o.hf_fc;
|
||||||
|
}
|
||||||
|
bool operator!=(const EventCondition& o) const noexcept { return !(*this == o); }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The event condition's state machine: one sample in, an event time or nothing out.
|
||||||
|
///
|
||||||
|
/// It holds no arming state and no capture state -- that is `TriggerEngine`'s job -- which
|
||||||
|
/// is what lets the buffer search run the *same* evaluator over retained samples and get
|
||||||
|
/// the answers the live trigger would have given.
|
||||||
|
///
|
||||||
|
/// Every condition but `Edge` is sequential: a pulse's width is only known at its closing
|
||||||
|
/// edge, and a dropout is a measured absence. Callers must therefore feed it **every**
|
||||||
|
/// sample in stream order, including samples they intend to ignore; a gap is a pulse it can
|
||||||
|
/// never close.
|
||||||
|
class EventDetector {
|
||||||
|
public:
|
||||||
|
/// What `feed()` returns when the sample is not an event.
|
||||||
|
static constexpr double kNoEvent = std::numeric_limits<double>::quiet_NaN();
|
||||||
|
|
||||||
|
/// Re-applying the same condition is a no-op, deliberately: the producer calls this on
|
||||||
|
/// every packet, and resetting there would restart the machine before any pulse could
|
||||||
|
/// close. A genuinely different condition resets, because its state means nothing.
|
||||||
|
///
|
||||||
|
/// `dt` is the stored sample interval (`Partition::slot_dt()`), needed for the
|
||||||
|
/// HF reject's pole. A no-op unless the condition or the interval changed.
|
||||||
|
void configure(const EventCondition& c, double dt) noexcept;
|
||||||
|
void reset() noexcept;
|
||||||
|
|
||||||
|
/// One sample, in stream order. Returns the event's own stream time, or `kNoEvent`.
|
||||||
|
///
|
||||||
|
/// There is no finite-value check: a NaN sample is benign here (every comparison against
|
||||||
|
/// it is false, so it cannot fire), and rejecting one would leave `prev_` at the last good
|
||||||
|
/// value and let the sample *after* the NaN fire a crossing that today cannot happen.
|
||||||
|
double feed(double value, double t) noexcept;
|
||||||
|
|
||||||
|
double last_value() const noexcept { return prev_; }
|
||||||
|
bool have_last() const noexcept { return have_prev_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool crossed_up(double v) const noexcept {
|
||||||
|
return prev_ < cond_.level && v >= cond_.level;
|
||||||
|
}
|
||||||
|
bool crossed_down(double v) const noexcept {
|
||||||
|
return prev_ > cond_.level && v <= cond_.level;
|
||||||
|
}
|
||||||
|
/// The HF reject, applied to every sample before any comparison.
|
||||||
|
double filter(double v) noexcept;
|
||||||
|
/// `up`/`down` for the edge condition, through the band if there is one.
|
||||||
|
void decide(double v, bool& up, bool& down) noexcept;
|
||||||
|
double feed_edge(double value, double t) noexcept;
|
||||||
|
double feed_pulse(double value, double t) noexcept;
|
||||||
|
double feed_window(double value, double t) noexcept;
|
||||||
|
double feed_dropout(double value, double t) noexcept;
|
||||||
|
/// Whether a measured width satisfies `cond_.cmp`. `Within`/`Outside` take their two
|
||||||
|
/// bounds in either order, so a panel cannot make an empty band by typing the larger
|
||||||
|
/// figure into the first spin.
|
||||||
|
bool qualifies(double width_s) const noexcept;
|
||||||
|
|
||||||
|
EventCondition cond_{};
|
||||||
|
bool have_prev_ = false;
|
||||||
|
double prev_ = 0.0;
|
||||||
|
bool in_pulse_ = false; ///< PulseWidth: an opening edge has been seen.
|
||||||
|
bool pulse_up_ = false; ///< PulseWidth: its polarity, so we know which edge closes it.
|
||||||
|
double pulse_start_s_ = 0.0;
|
||||||
|
bool inside_ = false; ///< Window: which side of the band the previous sample was.
|
||||||
|
double last_cross_s_ = 0.0; ///< Dropout: when the signal was last seen crossing.
|
||||||
|
bool dropped_ = false; ///< Dropout: the timeout has already been reported.
|
||||||
|
/// One-pole prefilter. `pole_a_ == 1` passes the input through, so "off"
|
||||||
|
/// costs no branch. `pole_y_` is the filtered value.
|
||||||
|
double dt_ = 0.0;
|
||||||
|
double pole_a_ = 1.0;
|
||||||
|
double pole_y_ = 0.0;
|
||||||
|
bool pole_have_ = false;
|
||||||
|
/// Which side of the band the signal was last *decided* to be on: +1 above,
|
||||||
|
/// -1 below, 0 undecided. A sample inside the band decides nothing.
|
||||||
|
int side_ = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
// For `RowSet::kMaxRows`: `--plots` names a row count, and one constant for the ceiling
|
// For `RowSet::kMaxRows`: `--plots` names a row count, and one constant for the ceiling
|
||||||
// is what stops the message and the clamp from drifting apart.
|
// is what stops the message and the clamp from drifting apart.
|
||||||
#include "plot_host.hpp"
|
#include "plot_host.hpp"
|
||||||
|
#include "source/registry.hpp"
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
@@ -38,24 +39,82 @@ double parse_double(const char* name, const std::string& v) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A count with an optional decimal suffix: `250000`, `64k`, `100M`, `1.5G`. k, M and G are 10^3, 10^6
|
||||||
|
/// and 10^9 -- a *sample* count, not a byte size -- and either case is accepted.
|
||||||
|
std::uint64_t parse_count(const char* name, const std::string& v) {
|
||||||
|
std::string digits = v;
|
||||||
|
double scale = 1.0;
|
||||||
|
if (!digits.empty()) {
|
||||||
|
switch (digits.back()) {
|
||||||
|
case 'k': case 'K': scale = 1.0e3; break;
|
||||||
|
case 'm': case 'M': scale = 1.0e6; break;
|
||||||
|
case 'g': case 'G': scale = 1.0e9; break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
if (scale != 1.0) digits.pop_back();
|
||||||
|
}
|
||||||
|
const double d = parse_double(name, digits) * scale;
|
||||||
|
if (!(d >= 1.0) || d > 1.0e12) fail(std::string(name) + " out of range: " + v);
|
||||||
|
return static_cast<std::uint64_t>(d + 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
SourceConfig Config::source_config() const {
|
||||||
|
SourceConfig out;
|
||||||
|
if (source_kind == "udps") {
|
||||||
|
out.set("server", server);
|
||||||
|
out.set("port", std::to_string(port));
|
||||||
|
if (!multicast.empty()) out.set("multicast", multicast);
|
||||||
|
if (!interface_addr.empty()) out.set("interface", interface_addr);
|
||||||
|
out.set("recv_buffer", std::to_string(recv_buffer_bytes));
|
||||||
|
}
|
||||||
|
for (const auto& kv : source_opts.items()) out.set(kv.first, kv.second);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string Config::session_host() const { return udpscope::session_host(source_kind, source_config()); }
|
||||||
|
std::uint16_t Config::session_port() const { return udpscope::session_port(source_kind, source_config()); }
|
||||||
|
|
||||||
void print_usage(std::FILE* out, const char* argv0) {
|
void print_usage(std::FILE* out, const char* argv0) {
|
||||||
|
std::string names;
|
||||||
|
for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id();
|
||||||
std::fprintf(out,
|
std::fprintf(out,
|
||||||
"usage: %s --server ADDR --port PORT [options]\n"
|
"usage: %s --server ADDR --port PORT [--source NAME] [options]\n"
|
||||||
"\n"
|
"\n"
|
||||||
" --server ADDR stream source IPv4 address (default 127.0.0.1)\n"
|
" --server ADDR stream source IPv4 address (default 127.0.0.1)\n"
|
||||||
" --port PORT UDP port (unicast) or TCP port (multicast) (default 8000)\n"
|
" --port PORT UDP port (unicast) or TCP port (multicast) (default 8000)\n"
|
||||||
" --multicast GROUP join GROUP for DATA instead of unicast\n"
|
" --multicast GROUP join GROUP for DATA instead of unicast\n"
|
||||||
" --interface ADDR local IPv4 of the interface to join on\n"
|
" --interface ADDR local IPv4 of the interface to join on\n"
|
||||||
" --buffer-mb MB size of each of the two sample buffers (default 200)\n"
|
" --source NAME source kind (%s; default udps). --server/--port/\n"
|
||||||
|
" --multicast/--interface configure udps\n"
|
||||||
|
" --source-opt K=V a setting for the source kind; repeatable\n"
|
||||||
|
" --buffer-samples N samples kept per acquired signal, e.g. 100M or 250000 (k, M\n"
|
||||||
|
" and G are decimal; default 10M). Rounded down to a power\n"
|
||||||
|
" of two: 100M gives 67108864\n"
|
||||||
|
" --memory-limit-mb MB hard ceiling on the sample rings, all signals together\n"
|
||||||
|
" (default 8192); the request is halved until it fits\n"
|
||||||
" --max-window SEC largest time window the toolbar offers (default 600)\n"
|
" --max-window SEC largest time window the toolbar offers (default 600)\n"
|
||||||
" --holdoff SEC default trigger hold-off (default 0.010)\n"
|
" --holdoff SEC default trigger hold-off (default 0.010)\n"
|
||||||
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
|
" --recv-buffer BYTES SO_RCVBUF for the receiver socket (default 8388608)\n"
|
||||||
" --plots N stacked plots to start with, 1..8 (default 1); rows are\n"
|
" --plots N stacked plots to start with, 1..8 (default 1); rows are\n"
|
||||||
" added and removed from the layout editor\n"
|
" added and removed from the layout editor\n"
|
||||||
|
" --capture-packets N store the last N raw datagrams on disk for forensics, in a\n"
|
||||||
|
" ring under the cache directory (1000 slots is ~62.6 MiB);\n"
|
||||||
|
" off unless given, and toggleable from the status bar\n"
|
||||||
|
" --relative-time take the plot's time from the packet counter and the\n"
|
||||||
|
" declared rate, not the stream's time signal; absolute\n"
|
||||||
|
" time is then kept for export only\n"
|
||||||
|
" --open FILE open an exported waveform (.npy) instead of a live source:\n"
|
||||||
|
" signals from its columns, no socket, no acquisition\n"
|
||||||
|
" --renderer NAME render with cairo (software), gl or vulkan. cairo also\n"
|
||||||
|
" exports LIBGL_ALWAYS_SOFTWARE=1, because the plot is a\n"
|
||||||
|
" GtkGLArea and still needs a GL context. Over SSH/X11\n"
|
||||||
|
" forwarding use cairo: Vulkan cannot make a swapchain there\n"
|
||||||
|
" and the GL area has no hardware context, so the window\n"
|
||||||
|
" never paints or allocates\n"
|
||||||
" -h, --help this text\n",
|
" -h, --help this text\n",
|
||||||
argv0);
|
argv0, names.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
Config parse_args(int argc, char** argv) {
|
Config parse_args(int argc, char** argv) {
|
||||||
@@ -77,9 +136,28 @@ Config parse_args(int argc, char** argv) {
|
|||||||
c.multicast = value_of(argc, argv, i, "--multicast");
|
c.multicast = value_of(argc, argv, i, "--multicast");
|
||||||
} else if (a == "--interface" || a.rfind("--interface=", 0) == 0) {
|
} else if (a == "--interface" || a.rfind("--interface=", 0) == 0) {
|
||||||
c.interface_addr = value_of(argc, argv, i, "--interface");
|
c.interface_addr = value_of(argc, argv, i, "--interface");
|
||||||
} else if (a == "--buffer-mb" || a.rfind("--buffer-mb=", 0) == 0) {
|
} else if (a == "--source" || a.rfind("--source=", 0) == 0) {
|
||||||
c.buffer_mb = parse_double("--buffer-mb", value_of(argc, argv, i, "--buffer-mb"));
|
const std::string v = value_of(argc, argv, i, "--source");
|
||||||
if (c.buffer_mb < 1.0) fail("--buffer-mb must be at least 1");
|
if (find_kind(v) == nullptr) {
|
||||||
|
std::string names;
|
||||||
|
for (const SourceKind* k : kinds()) names += (names.empty() ? "" : ", ") + k->id();
|
||||||
|
fail("unknown source '" + v + "' (available: " + names + ")");
|
||||||
|
}
|
||||||
|
c.source_kind = v;
|
||||||
|
c.source_specified = true;
|
||||||
|
} else if (a == "--source-opt" || a.rfind("--source-opt=", 0) == 0) {
|
||||||
|
const std::string v = value_of(argc, argv, i, "--source-opt");
|
||||||
|
if (!c.source_opts.parse_assignment(v)) fail("--source-opt must be key=value: " + v);
|
||||||
|
} else if (a == "--buffer-samples" || a.rfind("--buffer-samples=", 0) == 0) {
|
||||||
|
c.buffer_samples = parse_count("--buffer-samples", value_of(argc, argv, i, "--buffer-samples"));
|
||||||
|
c.buffer_samples_specified = true;
|
||||||
|
if (c.buffer_samples < 128) fail("--buffer-samples must be at least 128");
|
||||||
|
} else if (a == "--memory-limit-mb" || a.rfind("--memory-limit-mb=", 0) == 0) {
|
||||||
|
const double mb =
|
||||||
|
parse_double("--memory-limit-mb", value_of(argc, argv, i, "--memory-limit-mb"));
|
||||||
|
if (!(mb >= 1.0) || mb > 1.0e9) fail("--memory-limit-mb must be 1..1000000000 (MiB)");
|
||||||
|
c.memory_limit_mb = static_cast<std::uint64_t>(mb);
|
||||||
|
c.memory_limit_specified = true;
|
||||||
} else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) {
|
} else if (a == "--max-window" || a.rfind("--max-window=", 0) == 0) {
|
||||||
c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window"));
|
c.max_window_s = parse_double("--max-window", value_of(argc, argv, i, "--max-window"));
|
||||||
if (c.max_window_s <= 0.0) fail("--max-window must be positive");
|
if (c.max_window_s <= 0.0) fail("--max-window must be positive");
|
||||||
@@ -98,6 +176,24 @@ Config parse_args(int argc, char** argv) {
|
|||||||
fail("--plots must be 1.." + std::to_string(RowSet::kMaxRows) + ": " + v);
|
fail("--plots must be 1.." + std::to_string(RowSet::kMaxRows) + ": " + v);
|
||||||
}
|
}
|
||||||
c.plots = static_cast<int>(n);
|
c.plots = static_cast<int>(n);
|
||||||
|
} else if (a == "--capture-packets" || a.rfind("--capture-packets=", 0) == 0) {
|
||||||
|
const long n = std::strtol(value_of(argc, argv, i, "--capture-packets").c_str(), nullptr, 10);
|
||||||
|
if (n < 1 || n > 100000) fail("--capture-packets must be 1..100000");
|
||||||
|
c.capture_packets = static_cast<std::uint32_t>(n);
|
||||||
|
} else if (a == "--open" || a.rfind("--open=", 0) == 0) {
|
||||||
|
c.open_waveform = value_of(argc, argv, i, "--open");
|
||||||
|
} else if (a == "--renderer" || a.rfind("--renderer=", 0) == 0) {
|
||||||
|
const std::string v = value_of(argc, argv, i, "--renderer");
|
||||||
|
// Only the three a source or a forwarded display actually chooses between. Anything
|
||||||
|
// GTK does not know would fall back with a warning and leave the user reading a
|
||||||
|
// renderer failure that is really a typo, so it is refused here instead.
|
||||||
|
if (v != "cairo" && v != "gl" && v != "vulkan") {
|
||||||
|
fail("--renderer must be cairo, gl or vulkan: " + v);
|
||||||
|
}
|
||||||
|
c.renderer = v;
|
||||||
|
} else if (a == "--relative-time") {
|
||||||
|
c.relative_time = true;
|
||||||
|
c.relative_time_specified = true;
|
||||||
} else {
|
} else {
|
||||||
fail("unknown argument: " + a);
|
fail("unknown argument: " + a);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,10 +6,12 @@
|
|||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#include "source/source_config.hpp"
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
|
|
||||||
/// Everything the command line can say. Defaults are the ones the spec names
|
/// Everything the command line can say. Defaults are the ones the spec names
|
||||||
/// (200 MB per buffer, 10 ms trigger hold-off) unless the user overrides them.
|
/// (10 M samples per signal, 10 ms trigger hold-off) unless the user overrides them.
|
||||||
struct Config {
|
struct Config {
|
||||||
std::string server = "127.0.0.1"; ///< IPv4 dotted quad of the stream source.
|
std::string server = "127.0.0.1"; ///< IPv4 dotted quad of the stream source.
|
||||||
std::uint16_t port = 8000; ///< UDP port (unicast) or TCP port (multicast).
|
std::uint16_t port = 8000; ///< UDP port (unicast) or TCP port (multicast).
|
||||||
@@ -21,9 +23,38 @@ struct Config {
|
|||||||
/// `server` valid". The default stays a usable value for every other path.
|
/// `server` valid". The default stays a usable value for every other path.
|
||||||
bool server_specified = false;
|
bool server_specified = false;
|
||||||
|
|
||||||
/// Size of *each* of the two sample buffers, in megabytes. The application
|
/// `--source NAME`: which registered kind opens. `udps` is the default, and `--server/--port/
|
||||||
/// always allocates two identical buffers (spec item 1).
|
/// --multicast/--interface` keep meaning UDPS.
|
||||||
double buffer_mb = 200.0;
|
std::string source_kind = "udps";
|
||||||
|
/// Whether `--source` was given (the other half of "did the user choose a source").
|
||||||
|
bool source_specified = false;
|
||||||
|
/// Repeatable `--source-opt k=v`: the kind's own settings. For `udps` they overlay the fields above.
|
||||||
|
SourceConfig source_opts;
|
||||||
|
|
||||||
|
/// A source was named on the command line (`--server` or `--source`), so the chooser is skipped.
|
||||||
|
bool has_source() const noexcept { return server_specified || source_specified; }
|
||||||
|
/// The settings the kind's `make()` receives: for UDPS the four connection fields plus the receive
|
||||||
|
/// buffer, then `source_opts` on top.
|
||||||
|
SourceConfig source_config() const;
|
||||||
|
/// The (host, port) that name this source's session file; see `session_host()` in the registry.
|
||||||
|
std::string session_host() const;
|
||||||
|
std::uint16_t session_port() const;
|
||||||
|
|
||||||
|
/// The sample budget: how many samples to keep for **every acquired signal**, and for the shared
|
||||||
|
/// time ring. The rings are powers of two, so the request is rounded *down* to one (`buffer_ticks()`
|
||||||
|
/// in model.hpp) -- "up to" this many -- and the UI shows both figures. 10 M asks for 8 388 608.
|
||||||
|
std::uint64_t buffer_samples = 10'000'000;
|
||||||
|
/// Whether `--buffer-samples` was given. A stored configuration's sample budget fills in only when
|
||||||
|
/// the flag was *absent*: an explicit flag beats a saved setting, and "not asked" is not the same
|
||||||
|
/// question as "asked for 10 M".
|
||||||
|
bool buffer_samples_specified = false;
|
||||||
|
|
||||||
|
/// The hard ceiling on the ring storage, in MiB, for the whole application rather than per source.
|
||||||
|
/// `plan_partition()` halves the rings until they fit under it, so it can clip `buffer_samples`.
|
||||||
|
/// Default 8 GiB; the preferences carry it between runs and `--memory-limit-mb` overrides it.
|
||||||
|
std::uint64_t memory_limit_mb = 8192;
|
||||||
|
/// Whether `--memory-limit-mb` was given, so the stored preference only fills the gap.
|
||||||
|
bool memory_limit_specified = false;
|
||||||
|
|
||||||
/// Largest time window the toolbar offers, in seconds. The buffer partition is
|
/// Largest time window the toolbar offers, in seconds. The buffer partition is
|
||||||
/// sized to hold at least this much time (spec: decimation).
|
/// sized to hold at least this much time (spec: decimation).
|
||||||
@@ -35,6 +66,42 @@ struct Config {
|
|||||||
/// Number of stacked plots (rows) to start with, 1..`RowSet::kMaxRows`. Only the
|
/// Number of stacked plots (rows) to start with, 1..`RowSet::kMaxRows`. Only the
|
||||||
/// starting point: rows are added and removed from the layout editor.
|
/// starting point: rows are added and removed from the layout editor.
|
||||||
int plots = 1;
|
int plots = 1;
|
||||||
|
|
||||||
|
/// Take the plot's time from the packet counter and the declared sample rate rather than
|
||||||
|
/// from the stream's own time signal, so arrival and producer-clock jitter cannot move a
|
||||||
|
/// sample's x. Absolute stream time is still recorded, for export only (`todo.md` item 2).
|
||||||
|
bool relative_time = false;
|
||||||
|
|
||||||
|
/// Whether relative time was *stated* rather than left at its default -- by `--relative-time`, or
|
||||||
|
/// by the preferences, which the source chooser can reach before this run is planned. A source's
|
||||||
|
/// stored configuration is a statement from an *older* session, so it only fills in the gap.
|
||||||
|
bool relative_time_specified = false;
|
||||||
|
|
||||||
|
/// `--capture-packets N`: the ring size of the raw-datagram capture, 1..100000.
|
||||||
|
/// 0 means the flag was absent: the capture is off, and the popover button then
|
||||||
|
/// uses the 1000-slot default when the user toggles it.
|
||||||
|
std::uint32_t capture_packets = 0;
|
||||||
|
|
||||||
|
/// `--open FILE`: a `.npy` an earlier run exported, opened for viewing and analysis instead of a
|
||||||
|
/// live source. File mode and source mode are exclusive -- there is no acquisition behind a file,
|
||||||
|
/// and the chooser's Waveforms page sets this rather than the connection fields.
|
||||||
|
std::string open_waveform;
|
||||||
|
|
||||||
|
/// `--renderer NAME`: the GSK renderer to force, by exporting `GSK_RENDERER` **before any GTK
|
||||||
|
/// call**. Empty leaves GTK's own choice alone. `cairo` is the software preset over SSH/X11
|
||||||
|
/// forwarding: it also exports `LIBGL_ALWAYS_SOFTWARE=1`, because the plot's `GtkGLArea` asks
|
||||||
|
/// for a GL context whatever GSK draws with. `gl` is the faster alternative when indirect GL
|
||||||
|
/// works. The variables are read when the renderer/context is created, so setting them anywhere
|
||||||
|
/// after `gtk_init()`/`adw_init()` is a no-op -- which is why this is a `Config` field applied
|
||||||
|
/// at the top of `main()` and not a preference.
|
||||||
|
std::string renderer;
|
||||||
|
|
||||||
|
/// The source chooser's "Guided tour" entry. The tour is drawn on the application's own window,
|
||||||
|
/// which does not exist while the chooser runs, so the request rides on the one value `main()`
|
||||||
|
/// owns and hands to both the chooser and the front end (`open_waveform` and the preferences'
|
||||||
|
/// relative time cross the same way) and the window starts the tour once it is up and laid out.
|
||||||
|
/// It is *not* a preference: it describes this request and nothing else, so nothing persists it.
|
||||||
|
bool tour_pending = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
|
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
@@ -26,14 +27,28 @@ using plot::u64;
|
|||||||
/// live ring buffers switched by a pending flag, plus a display-epoch shift)
|
/// live ring buffers switched by a pending flag, plus a display-epoch shift)
|
||||||
/// could not stop.
|
/// could not stop.
|
||||||
struct EventSnapshot {
|
struct EventSnapshot {
|
||||||
/// Seconds, monotonic -- edge-relative for a trigger event, the stream's own for a
|
/// Seconds, monotonic, and **in the coordinates the axis reads**: a trigger event's
|
||||||
/// pause view. **f64, like the live ring it is cut from** (see `BufferStore::time()`):
|
/// stamps are edge-relative and a pause view's are relative to the instant it froze,
|
||||||
/// an event's stamps are small enough that f32 would nearly do, but a *pause* is
|
/// which is what `x_offset` below records. **f64, like the live ring it is cut from**
|
||||||
/// deliberately not rebased, so its stamps carry the full stream time and quantise at
|
/// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/
|
||||||
/// the same 3.8 us per 32 s of uptime the live ring did. Keeping the two the same type
|
/// `publish_pause_view()` a straight copy.
|
||||||
/// also makes the cut in `publish_event()`/`publish_pause_view()` a straight copy.
|
|
||||||
std::vector<f64> stamps;
|
std::vector<f64> stamps;
|
||||||
|
/// Absolute stream seconds on the same indices as `stamps`, or empty when the store had no
|
||||||
|
/// absolute ring. **Not** rebased by `x_offset` or by the trigger edge: a snapshot outlives
|
||||||
|
/// the epoch that produced it, so it stores the absolute value outright.
|
||||||
|
std::vector<f64> abs_stamps;
|
||||||
std::vector<std::vector<f32>> values; ///< one array per channel, same length
|
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 pre_s = 0.0; ///< seconds of pre-trigger data kept
|
||||||
double post_s = 0.0; ///< seconds of post-trigger data kept
|
double post_s = 0.0; ///< seconds of post-trigger data kept
|
||||||
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
|
std::vector<plot::Range> y; ///< per-channel value bounds, precomputed
|
||||||
@@ -49,8 +64,30 @@ struct EventSnapshot {
|
|||||||
/// already O(n), and the same channel on two rows shares it instead of building and
|
/// already O(n), and the same channel on two rows shares it instead of building and
|
||||||
/// storing it twice.
|
/// storing it twice.
|
||||||
std::vector<plot::data::MinMaxPyramid> pyramids;
|
std::vector<plot::data::MinMaxPyramid> pyramids;
|
||||||
|
/// The channel *names* `values` is indexed by, so a snapshot can be re-bound after the channel
|
||||||
|
/// list was rebuilt -- and renumbered -- under it: a persistence layer names its signal and
|
||||||
|
/// resolves that name here, exactly as the trigger engine names its channel and for the same
|
||||||
|
/// reason (an index is a position in a list that any enable, disable or CONFIG renumbers).
|
||||||
|
/// Empty on a snapshot nobody re-binds later (a pause view).
|
||||||
|
std::vector<std::string> channel_names;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Bytes one snapshot holds: both stamp arrays, every channel's samples, the pyramids built for
|
||||||
|
/// them and the names above. The number the persistence retention is measured in -- a retained
|
||||||
|
/// snapshot is a whole window copied out of the ring, so what bounds it is bytes, not a count.
|
||||||
|
inline std::size_t snapshot_bytes(const EventSnapshot& s) {
|
||||||
|
std::size_t bytes = s.stamps.size() * sizeof(f64) + s.abs_stamps.size() * sizeof(f64) +
|
||||||
|
s.y.size() * sizeof(plot::Range) +
|
||||||
|
s.channel_names.size() * sizeof(std::string);
|
||||||
|
for (const std::vector<f32>& v : s.values) bytes += v.size() * sizeof(f32);
|
||||||
|
// A pyramid entry is 32 bytes (`MinMaxPyramid::Entry`), one per level-`l` bucket of
|
||||||
|
// `kBucket << l` samples -- the whole index is about one byte per sample.
|
||||||
|
for (const plot::data::MinMaxPyramid& p : s.pyramids) {
|
||||||
|
for (u32 l = 0; l < p.level_count(); ++l) bytes += p.level_size(l) * 32u;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
/// A plot::data::Series view of one channel of an EventSnapshot. The stamps are
|
/// A plot::data::Series view of one channel of an EventSnapshot. The stamps are
|
||||||
/// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns
|
/// near-uniform (the arrival clock), so dx is the mean spacing and x_at returns
|
||||||
/// the exact stamp; gather decimates by 2^level like the ring series does.
|
/// the exact stamp; gather decimates by 2^level like the ring series does.
|
||||||
@@ -108,7 +145,7 @@ class EventSeries final : public plot::data::Series {
|
|||||||
/// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever
|
/// 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
|
/// dropped from it, so `window_base()` is 0 and a window index *is* the absolute one
|
||||||
/// (unlike the ring, whose window slides).
|
/// (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 mode =
|
||||||
plot::data::GatherMode::Envelope) const override {
|
plot::data::GatherMode::Envelope) const override {
|
||||||
if (channel_ >= snap_->values.size()) return;
|
if (channel_ >= snap_->values.size()) return;
|
||||||
@@ -118,17 +155,17 @@ class EventSeries final : public plot::data::Series {
|
|||||||
const u64 b = std::min(begin, n);
|
const u64 b = std::min(begin, n);
|
||||||
const u64 e = std::min(std::max(end, b), n);
|
const u64 e = std::min(std::max(end, b), n);
|
||||||
if (b >= e) return;
|
if (b >= e) return;
|
||||||
// x **relative to x0()**, which is the library's gather contract: the renderer
|
// x **relative to `x_origin`**, which is the library's gather contract: the
|
||||||
// adds `x0()` back before mapping to the screen. Emitting the absolute stamp made
|
// renderer adds that same origin back before mapping to the screen. Emitting the
|
||||||
// it add x0 twice, so the whole capture was drawn one `x0()` -- a full
|
// absolute stamp made it add an origin twice, so the whole capture was drawn one
|
||||||
// pre-trigger window -- to the left of the axis it had just been measured
|
// `x0()` -- a full pre-trigger window -- to the left of the axis it had just been
|
||||||
// against. The event's own stamps are small (edge-relative, so [-pre, +post]),
|
// measured against. The event's own stamps are small (edge-relative, so
|
||||||
// which is why that shift looked like a plausible picture: with a 50/50 split the
|
// [-pre, +post]), which is why that shift looked like a plausible picture: with a
|
||||||
// right half of the plot was simply empty, zooming out showed the trace ending at
|
// 50/50 split the right half of the plot was simply empty, zooming out showed the
|
||||||
// the x = 0 marker, and zooming in past the shift showed nothing at all while the
|
// trace ending at the x = 0 marker, and zooming in past the shift showed nothing
|
||||||
// crosshair -- which reads `x_at()`, never `gather()` -- went on reporting the
|
// at all while the crosshair -- which reads `x_at()`, never `gather()` -- went on
|
||||||
// right values.
|
// reporting the right values.
|
||||||
const f64 base = x0();
|
const f64 base = x_origin;
|
||||||
const auto rel = [&](u64 i) {
|
const auto rel = [&](u64 i) {
|
||||||
return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base);
|
return static_cast<f32>(stamps[static_cast<std::size_t>(i)] - base);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#include "event_store.hpp"
|
||||||
|
|
||||||
|
#include <dirent.h>
|
||||||
|
#include <sys/stat.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <ctime>
|
||||||
|
|
||||||
|
#include "layout_store.hpp"
|
||||||
|
#include "waveform.hpp"
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// One directory under the cache holds *every* source's events, and each source gets a folder inside
|
||||||
|
/// it: `<cache>/udpscope/events/<source>/`. The other way round -- a folder per source with an
|
||||||
|
/// `events` inside -- spreads one feature over as many directories as there are sources, and turns
|
||||||
|
/// "where are my events" into a question about which host you last used.
|
||||||
|
constexpr const char* kEventsSubdir = "/events";
|
||||||
|
|
||||||
|
/// `2026-10-09T11:32:00` in local time, plus the sequence number: two events inside one second are
|
||||||
|
/// ordinary (a 5 Hz trigger latches five times a second), and a stem that collides would overwrite
|
||||||
|
/// the event it collided with.
|
||||||
|
std::string event_stem(double wall_time, std::uint64_t seq) {
|
||||||
|
const std::time_t t = static_cast<std::time_t>(wall_time);
|
||||||
|
std::tm tm{};
|
||||||
|
localtime_r(&t, &tm);
|
||||||
|
char buf[64];
|
||||||
|
std::strftime(buf, sizeof(buf), "%Y%m%d-%H%M%S", &tm);
|
||||||
|
char stem[96];
|
||||||
|
std::snprintf(stem, sizeof(stem), "%s-%04llu", buf,
|
||||||
|
static_cast<unsigned long long>(seq % 10000ull));
|
||||||
|
return stem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `a.npy` -> `a`; anything else -> "".
|
||||||
|
std::string stem_of(const std::string& file, const char* ext) {
|
||||||
|
const std::size_t n = std::strlen(ext);
|
||||||
|
if (file.size() <= n || file.compare(file.size() - n, n, ext) != 0) return {};
|
||||||
|
return file.substr(0, file.size() - n);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The event's picture, drawn from its samples: one horizontal strip per signal, min/max per pixel
|
||||||
|
/// column, the trigger edge as a brighter line. A 24-bit BMP -- the header is nine words and the rest
|
||||||
|
/// is the pixels, so it needs no encoder, and a person can open it with anything.
|
||||||
|
///
|
||||||
|
/// The palette is fixed rather than the plots': this is navigation, and what has to be told apart at
|
||||||
|
/// a glance is one *event* from another, not one signal from another (for which the names are on the
|
||||||
|
/// row above).
|
||||||
|
///
|
||||||
|
/// The height is **per strip**, not for the whole picture: with a fixed total, a six-signal event got
|
||||||
|
/// 26 pixels a signal and an eight-signal one got 20, which is a smear and not a waveform -- the
|
||||||
|
/// picture has to grow with the signal count, and it is the *viewer* that scales it down to a row.
|
||||||
|
bool write_preview(const ExportTable& table, const std::string& path, int width = 480,
|
||||||
|
int strip_h = 44) {
|
||||||
|
if (table.empty() || table.columns.empty()) return false;
|
||||||
|
const std::size_t rows = table.rows();
|
||||||
|
const std::size_t strips = table.columns.size();
|
||||||
|
const int total_h = strip_h * static_cast<int>(strips);
|
||||||
|
static const std::uint32_t kPalette[] = {0x4FA3FFu, 0xFF7A4Fu, 0x6EE07Au, 0xE06ED0u,
|
||||||
|
0xFFD24Fu, 0x6FD8E0u, 0xB9A0FFu, 0xE0E0E0u};
|
||||||
|
|
||||||
|
// One byte per pixel per channel, bottom-up and **blue first** -- both are BMP's own order, and
|
||||||
|
// writing RGB here is what made every external viewer show the traces in the wrong colour.
|
||||||
|
const int row_bytes = ((width * 3) + 3) & ~3;
|
||||||
|
std::vector<unsigned char> pixels(std::size_t(row_bytes) * total_h, 0);
|
||||||
|
const auto put = [&](int x, int y, std::uint32_t rgb, double alpha) {
|
||||||
|
if (x < 0 || y < 0 || x >= width || y >= total_h) return;
|
||||||
|
unsigned char* p = &pixels[std::size_t(total_h - 1 - y) * row_bytes + std::size_t(x) * 3];
|
||||||
|
const double a = std::clamp(alpha, 0.0, 1.0);
|
||||||
|
p[0] = static_cast<unsigned char>(p[0] * (1.0 - a) + (rgb & 0xFF) * a);
|
||||||
|
p[1] = static_cast<unsigned char>(p[1] * (1.0 - a) + ((rgb >> 8) & 0xFF) * a);
|
||||||
|
p[2] = static_cast<unsigned char>(p[2] * (1.0 - a) + ((rgb >> 16) & 0xFF) * a);
|
||||||
|
};
|
||||||
|
// The background: a dark field, so the traces read the way they do on screen.
|
||||||
|
for (int y = 0; y < total_h; ++y) {
|
||||||
|
for (int x = 0; x < width; ++x) put(x, y, 0x101418u, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (std::size_t c = 0; c < strips; ++c) {
|
||||||
|
const std::vector<f32>& values = table.columns[c].values;
|
||||||
|
if (values.empty()) continue;
|
||||||
|
double lo = values[0];
|
||||||
|
double hi = values[0];
|
||||||
|
for (f32 v : values) {
|
||||||
|
if (!std::isfinite(v)) continue;
|
||||||
|
lo = std::min(lo, double(v));
|
||||||
|
hi = std::max(hi, double(v));
|
||||||
|
}
|
||||||
|
if (!(hi > lo)) {
|
||||||
|
hi = lo + 1.0;
|
||||||
|
}
|
||||||
|
const int base = static_cast<int>(c) * strip_h;
|
||||||
|
const std::uint32_t colour = kPalette[c % (sizeof(kPalette) / sizeof(kPalette[0]))];
|
||||||
|
for (int x = 0; x < width; ++x) {
|
||||||
|
const std::size_t from = rows * std::size_t(x) / std::size_t(width);
|
||||||
|
const std::size_t to = std::max(from + 1, rows * std::size_t(x + 1) / std::size_t(width));
|
||||||
|
double vmin = 1e300;
|
||||||
|
double vmax = -1e300;
|
||||||
|
for (std::size_t i = from; i < to && i < rows; ++i) {
|
||||||
|
const double v = values[i];
|
||||||
|
if (!std::isfinite(v)) continue;
|
||||||
|
vmin = std::min(vmin, v);
|
||||||
|
vmax = std::max(vmax, v);
|
||||||
|
}
|
||||||
|
if (vmin > vmax) continue;
|
||||||
|
const auto to_y = [&](double v) {
|
||||||
|
const double frac = (v - lo) / (hi - lo);
|
||||||
|
return base + strip_h - 1 - static_cast<int>(frac * (strip_h - 1));
|
||||||
|
};
|
||||||
|
const int y0 = std::clamp(to_y(vmax), base, base + strip_h - 1);
|
||||||
|
const int y1 = std::clamp(to_y(vmin), base, base + strip_h - 1);
|
||||||
|
for (int y = y0; y <= y1; ++y) put(x, y, colour, 1.0);
|
||||||
|
}
|
||||||
|
// The strip's own baseline, so an empty stretch reads as a flat trace and not as a gap.
|
||||||
|
for (int x = 0; x < width; ++x) {
|
||||||
|
const int y = base + strip_h - 1;
|
||||||
|
put(x, y, 0x303840u, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The trigger edge: the snapshot's x = 0, which is at the same fraction of every event's window.
|
||||||
|
if (!table.time.empty()) {
|
||||||
|
const double t0 = table.time.front();
|
||||||
|
const double t1 = table.time.back();
|
||||||
|
if (t1 > t0) {
|
||||||
|
const int x = static_cast<int>((0.0 - t0) / (t1 - t0) * (width - 1));
|
||||||
|
for (int y = 0; y < total_h; ++y) put(x, y, 0xFFE9A0u, 0.85);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::FILE* f = std::fopen(path.c_str(), "wb");
|
||||||
|
if (f == nullptr) return false;
|
||||||
|
const std::uint32_t image_bytes = std::uint32_t(row_bytes) * std::uint32_t(total_h);
|
||||||
|
const std::uint32_t file_bytes = 54u + image_bytes;
|
||||||
|
unsigned char header[54] = {};
|
||||||
|
header[0] = 'B';
|
||||||
|
header[1] = 'M';
|
||||||
|
const auto put32 = [&](int at, std::uint32_t v) {
|
||||||
|
header[at] = static_cast<unsigned char>(v & 0xFF);
|
||||||
|
header[at + 1] = static_cast<unsigned char>((v >> 8) & 0xFF);
|
||||||
|
header[at + 2] = static_cast<unsigned char>((v >> 16) & 0xFF);
|
||||||
|
header[at + 3] = static_cast<unsigned char>((v >> 24) & 0xFF);
|
||||||
|
};
|
||||||
|
put32(2, file_bytes);
|
||||||
|
put32(10, 54u);
|
||||||
|
put32(14, 40u);
|
||||||
|
put32(18, std::uint32_t(width));
|
||||||
|
put32(22, std::uint32_t(total_h));
|
||||||
|
header[26] = 1; // planes
|
||||||
|
header[28] = 24; // bits per pixel
|
||||||
|
put32(34, image_bytes);
|
||||||
|
put32(38, 2835u); // 72 dpi, in pixels per metre
|
||||||
|
put32(42, 2835u);
|
||||||
|
std::fwrite(header, 1, sizeof(header), f);
|
||||||
|
std::fwrite(pixels.data(), 1, pixels.size(), f);
|
||||||
|
std::fclose(f);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
std::string event_meta_text(const EventRecord& r) {
|
||||||
|
char buf[512];
|
||||||
|
std::string out;
|
||||||
|
std::snprintf(buf, sizeof(buf), "wall_time = %.6f\n", r.wall_time);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "duration_s = %.6f\n", r.duration_s);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "pre_s = %.6f\n", r.pre_s);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "post_s = %.6f\n", r.post_s);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "edge = %.6f\n", r.edge);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "rate_hz = %.6g\n", r.rate_hz);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "signals = %zu\n", r.signals);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "samples = %zu\n", r.samples);
|
||||||
|
out += buf;
|
||||||
|
std::snprintf(buf, sizeof(buf), "seq = %llu\n", static_cast<unsigned long long>(r.seq));
|
||||||
|
out += buf;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool event_meta_parse(const std::string& text, EventRecord* out) {
|
||||||
|
if (out == nullptr) return false;
|
||||||
|
bool any = false;
|
||||||
|
std::size_t i = 0;
|
||||||
|
while (i < text.size()) {
|
||||||
|
const std::size_t end = text.find('\n', i);
|
||||||
|
const std::string line = text.substr(i, end == std::string::npos ? std::string::npos : end - i);
|
||||||
|
i = end == std::string::npos ? text.size() : end + 1;
|
||||||
|
const std::size_t eq = line.find('=');
|
||||||
|
if (eq == std::string::npos) continue;
|
||||||
|
std::string key = line.substr(0, eq);
|
||||||
|
std::string value = line.substr(eq + 1);
|
||||||
|
while (!key.empty() && (key.back() == ' ' || key.back() == '\t')) key.pop_back();
|
||||||
|
while (!value.empty() && (value.front() == ' ' || value.front() == '\t')) value.erase(0, 1);
|
||||||
|
const double number = std::atof(value.c_str());
|
||||||
|
if (key == "wall_time") {
|
||||||
|
out->wall_time = number;
|
||||||
|
any = true;
|
||||||
|
} else if (key == "duration_s") {
|
||||||
|
out->duration_s = number;
|
||||||
|
any = true;
|
||||||
|
} else if (key == "pre_s") {
|
||||||
|
out->pre_s = number;
|
||||||
|
any = true;
|
||||||
|
} else if (key == "post_s") {
|
||||||
|
out->post_s = number;
|
||||||
|
any = true;
|
||||||
|
} else if (key == "edge") {
|
||||||
|
out->edge = number;
|
||||||
|
any = true;
|
||||||
|
} else if (key == "rate_hz") {
|
||||||
|
out->rate_hz = number;
|
||||||
|
any = true;
|
||||||
|
} else if (key == "signals") {
|
||||||
|
out->signals = static_cast<std::size_t>(std::atoll(value.c_str()));
|
||||||
|
any = true;
|
||||||
|
} else if (key == "samples") {
|
||||||
|
out->samples = static_cast<std::size_t>(std::atoll(value.c_str()));
|
||||||
|
any = true;
|
||||||
|
} else if (key == "seq") {
|
||||||
|
out->seq = static_cast<std::uint64_t>(std::strtoull(value.c_str(), nullptr, 10));
|
||||||
|
any = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string default_events_dir() { return std::string(cache_dir()) + kEventsSubdir; }
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// A path component from a user-visible name: alphanumerics, `.`, `_` and `-` kept, everything else
|
||||||
|
/// replaced. The same rule the layout store applies to a configuration name, and for the same
|
||||||
|
/// reason -- a name typed with a slash in it must not be able to leave the directory.
|
||||||
|
std::string sanitize_component(const std::string& in) {
|
||||||
|
std::string safe;
|
||||||
|
safe.reserve(in.size());
|
||||||
|
for (char c : in) {
|
||||||
|
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||||
|
c == '.' || c == '_' || c == '-';
|
||||||
|
safe.push_back(ok ? c : '_');
|
||||||
|
}
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The directory a source's configuration writes into: `<root>/<source>/<configuration>/`, or the
|
||||||
|
/// source's own directory when it has no configuration name (a store built without one).
|
||||||
|
std::string event_dir(const std::string& root, const std::string& source,
|
||||||
|
const std::string& configuration) {
|
||||||
|
std::string dir = root + "/" + sanitize_component(source);
|
||||||
|
if (!configuration.empty()) dir += "/" + sanitize_component(configuration);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
EventStore::EventStore(std::string source, std::string base_dir, std::string configuration)
|
||||||
|
: root_(base_dir.empty() ? default_events_dir() : std::move(base_dir)),
|
||||||
|
source_(std::move(source)),
|
||||||
|
configuration_(std::move(configuration)) {
|
||||||
|
if (source_.empty()) {
|
||||||
|
root_.clear();
|
||||||
|
configuration_.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
dir_ = event_dir(root_, source_, configuration_);
|
||||||
|
// **An upgrade keeps its events.** Before configurations came in, a source's events sat directly
|
||||||
|
// in `<root>/<source>/`; the first store opened under a configuration adopts every loose event
|
||||||
|
// file it finds there, so the cache a user already has is the cache the new folder lists.
|
||||||
|
if (!configuration_.empty()) {
|
||||||
|
const std::string from = root_ + "/" + sanitize_component(source_);
|
||||||
|
DIR* d = ::opendir(from.c_str());
|
||||||
|
if (d != nullptr) {
|
||||||
|
bool moved = false;
|
||||||
|
while (dirent* e = ::readdir(d)) {
|
||||||
|
const std::string name = e->d_name;
|
||||||
|
if (name == "." || name == "..") continue;
|
||||||
|
const bool event_file = name.size() > 4 &&
|
||||||
|
(name.compare(name.size() - 4, 4, ".npy") == 0 ||
|
||||||
|
name.compare(name.size() - 4, 4, ".txt") == 0 ||
|
||||||
|
name.compare(name.size() - 4, 4, ".bmp") == 0);
|
||||||
|
if (!event_file) continue;
|
||||||
|
if (!moved) {
|
||||||
|
mkdirs(dir_);
|
||||||
|
moved = true;
|
||||||
|
}
|
||||||
|
std::rename((from + "/" + name).c_str(), (dir_ + "/" + name).c_str());
|
||||||
|
}
|
||||||
|
::closedir(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool EventStore::set_configuration(const std::string& configuration) {
|
||||||
|
if (source_.empty() || configuration == configuration_) return false;
|
||||||
|
configuration_ = configuration;
|
||||||
|
dir_ = event_dir(root_, source_, configuration_);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string EventStore::save(const ExportTable& table, const EventRecord& meta, std::size_t keep) {
|
||||||
|
if (dir_.empty() || table.empty()) return {};
|
||||||
|
mkdirs(dir_);
|
||||||
|
const std::string stem = event_stem(meta.wall_time > 0.0 ? meta.wall_time : double(std::time(nullptr)),
|
||||||
|
meta.seq);
|
||||||
|
const std::string samples = dir_ + "/" + stem + ".npy";
|
||||||
|
std::string error;
|
||||||
|
if (!write_npy(samples, table, error)) {
|
||||||
|
std::fprintf(stderr, "[udpscope] event cache: %s\n", error.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
EventRecord record = meta;
|
||||||
|
record.stem = stem;
|
||||||
|
std::string text = event_meta_text(record);
|
||||||
|
if (std::FILE* f = std::fopen((dir_ + "/" + stem + ".txt").c_str(), "wb"); f != nullptr) {
|
||||||
|
std::fwrite(text.data(), 1, text.size(), f);
|
||||||
|
std::fclose(f);
|
||||||
|
}
|
||||||
|
write_preview(table, dir_ + "/" + stem + ".bmp");
|
||||||
|
prune(keep);
|
||||||
|
return samples;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string EventStore::redraw_preview(const EventRecord& record) const {
|
||||||
|
if (dir_.empty() || record.stem.empty() || record.file.empty()) return {};
|
||||||
|
WaveformReader reader;
|
||||||
|
std::string error;
|
||||||
|
if (!reader.open(record.file, &error)) return {};
|
||||||
|
// The whole event in one table, which an event can afford where an export cannot: it is bounded by
|
||||||
|
// the trigger's own pre/post window, so this is seconds of samples and not a session's worth.
|
||||||
|
ExportTable table;
|
||||||
|
table.columns.resize(reader.names().size());
|
||||||
|
for (std::size_t c = 0; c < table.columns.size(); ++c) {
|
||||||
|
table.columns[c].name = reader.names()[c];
|
||||||
|
table.columns[c].values.reserve(reader.rows());
|
||||||
|
}
|
||||||
|
table.time.reserve(reader.rows());
|
||||||
|
std::vector<float> values(reader.names().size(), 0.0f);
|
||||||
|
double t = 0.0;
|
||||||
|
double abs_t = 0.0;
|
||||||
|
while (reader.next(&t, &abs_t, values.data())) {
|
||||||
|
table.time.push_back(t);
|
||||||
|
for (std::size_t c = 0; c < table.columns.size(); ++c) {
|
||||||
|
table.columns[c].values.push_back(values[c]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::string path = dir_ + "/" + record.stem + ".bmp";
|
||||||
|
if (!write_preview(table, path)) return {};
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<EventRecord> EventStore::list() const {
|
||||||
|
std::vector<EventRecord> out;
|
||||||
|
if (dir_.empty()) return out;
|
||||||
|
DIR* d = ::opendir(dir_.c_str());
|
||||||
|
if (d == nullptr) return out;
|
||||||
|
while (const dirent* e = ::readdir(d)) {
|
||||||
|
const std::string stem = stem_of(e->d_name, ".txt");
|
||||||
|
if (stem.empty()) continue;
|
||||||
|
std::FILE* f = std::fopen((dir_ + "/" + e->d_name).c_str(), "rb");
|
||||||
|
if (f == nullptr) continue;
|
||||||
|
std::string text;
|
||||||
|
char buf[512];
|
||||||
|
std::size_t n = 0;
|
||||||
|
while ((n = std::fread(buf, 1, sizeof(buf), f)) > 0) text.append(buf, n);
|
||||||
|
std::fclose(f);
|
||||||
|
EventRecord r;
|
||||||
|
if (!event_meta_parse(text, &r)) continue;
|
||||||
|
r.stem = stem;
|
||||||
|
r.file = dir_ + "/" + stem + ".npy";
|
||||||
|
if (::access(r.file.c_str(), R_OK) != 0) continue; // a sidecar without its samples
|
||||||
|
struct stat sb {};
|
||||||
|
if (::stat((dir_ + "/" + stem + ".bmp").c_str(), &sb) == 0) {
|
||||||
|
r.thumbnail = dir_ + "/" + stem + ".bmp";
|
||||||
|
}
|
||||||
|
out.push_back(std::move(r));
|
||||||
|
}
|
||||||
|
::closedir(d);
|
||||||
|
// Newest first by the *record's* time, not the file's: a copy or a touch changes the mtime, and the
|
||||||
|
// list is about when the event happened.
|
||||||
|
std::stable_sort(out.begin(), out.end(), [](const EventRecord& a, const EventRecord& b) {
|
||||||
|
if (a.wall_time != b.wall_time) return a.wall_time > b.wall_time;
|
||||||
|
return a.seq > b.seq;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool EventStore::forget(const std::string& stem) {
|
||||||
|
if (dir_.empty() || stem.empty()) return false;
|
||||||
|
const std::string base = dir_ + "/" + stem;
|
||||||
|
const bool gone = ::unlink((base + ".npy").c_str()) == 0;
|
||||||
|
::unlink((base + ".txt").c_str());
|
||||||
|
::unlink((base + ".bmp").c_str());
|
||||||
|
return gone;
|
||||||
|
}
|
||||||
|
|
||||||
|
void EventStore::prune(std::size_t keep) const {
|
||||||
|
const std::vector<EventRecord> all = list();
|
||||||
|
for (std::size_t i = keep; i < all.size(); ++i) {
|
||||||
|
const std::string base = dir_ + "/" + all[i].stem;
|
||||||
|
::unlink((base + ".npy").c_str());
|
||||||
|
::unlink((base + ".txt").c_str());
|
||||||
|
::unlink((base + ".bmp").c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "export.hpp"
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// What a cached event is, without loading its samples: the sidecar's fields, and what the pane
|
||||||
|
/// shows in one row.
|
||||||
|
struct EventRecord {
|
||||||
|
std::string stem; ///< the file's name without the extension
|
||||||
|
std::string file; ///< `<stem>.npy`, the event's table in the export format
|
||||||
|
std::string thumbnail; ///< `<stem>.bmp`, the preview drawn from the samples; else empty
|
||||||
|
double wall_time = 0.0; ///< CLOCK_REALTIME at the latch, for the row's label
|
||||||
|
double duration_s = 0.0; ///< pre + post
|
||||||
|
double pre_s = 0.0;
|
||||||
|
double post_s = 0.0;
|
||||||
|
double edge = 0.0; ///< the stream time of the event's x = 0
|
||||||
|
double rate_hz = 0.0; ///< the stored sample rate at the latch
|
||||||
|
std::size_t signals = 0; ///< columns in the file
|
||||||
|
std::size_t samples = 0; ///< rows
|
||||||
|
std::uint64_t seq = 0; ///< the run's own event counter, so two events in one second differ
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The cache of captured events: `<cache_dir>/events/<source>/`, one `.npy` per event written in
|
||||||
|
/// the **export format** -- so `--open` and the chooser's Waveforms page read it back with the
|
||||||
|
/// loader that already exists -- beside a `.txt` sidecar holding what the list shows without
|
||||||
|
/// loading the samples, and whatever thumbnail the caller rendered.
|
||||||
|
///
|
||||||
|
/// No GTK: the caller renders the thumbnail and hands over a path, exactly as it hands over the
|
||||||
|
/// table. The store owns naming, the sidecar, pruning and listing, which is what makes it testable
|
||||||
|
/// without a window.
|
||||||
|
/// The directory every source's event folder is created under when no preference says otherwise:
|
||||||
|
/// `<cache>/udpscope/events`. Exposed because the preferences dialog shows it -- "the standard
|
||||||
|
/// location" is not an answer a user can act on, and a second copy of the path computed in the dialog
|
||||||
|
/// is a second thing to get wrong.
|
||||||
|
std::string default_events_dir();
|
||||||
|
|
||||||
|
class EventStore {
|
||||||
|
public:
|
||||||
|
/// `source` is the `<host>-<port>` pair, the same key the packet capture uses. `configuration`
|
||||||
|
/// is the source's **named configuration** -- the same name the session files and the packet
|
||||||
|
/// capture use, `default` for a source that never saved one: an event's trigger, its channel set
|
||||||
|
/// and its units belong to the configuration it was latched under, so two configurations of one
|
||||||
|
/// source must not list each other's events. The directory is
|
||||||
|
/// `<base>/<source>/<configuration>/`; an empty configuration is the legacy layout
|
||||||
|
/// (`<base>/<source>/`), and the first construction under a configuration **adopts** the loose
|
||||||
|
/// event files it finds there, so an upgrade keeps the events it had.
|
||||||
|
///
|
||||||
|
/// `base_dir` is the directory every source's own folder is created under; empty means
|
||||||
|
/// `<cache>/udpscope/events`. It comes from the preferences (item 9), because a user with a small
|
||||||
|
/// home partition and a second disk should not have to choose between events and free space.
|
||||||
|
explicit EventStore(std::string source = {}, std::string base_dir = {},
|
||||||
|
std::string configuration = {});
|
||||||
|
|
||||||
|
/// Points the store at another configuration of the same source, for the configuration switch
|
||||||
|
/// the session has: only the directory changes, and `true` means it did -- the caller then
|
||||||
|
/// refreshes whatever lists the cache. A store with no source (`empty_dir()`) never changes.
|
||||||
|
bool set_configuration(const std::string& configuration);
|
||||||
|
|
||||||
|
/// The configuration the store currently writes to (the constructor's, or `set_configuration`'s).
|
||||||
|
const std::string& configuration() const noexcept { return configuration_; }
|
||||||
|
|
||||||
|
/// How many events to keep, newest first. The oldest beyond it are deleted on every save; item 9's
|
||||||
|
/// preferences are where this becomes the user's number.
|
||||||
|
static constexpr std::size_t kDefaultKeep = 32;
|
||||||
|
|
||||||
|
const std::string& dir() const noexcept { return dir_; }
|
||||||
|
bool empty_dir() const noexcept { return dir_.empty(); }
|
||||||
|
|
||||||
|
/// Writes `table` as one event, with `meta` in the sidecar and a preview drawn beside it. Returns
|
||||||
|
/// the samples' path, or an empty string on failure -- a cache that cannot be written must not stop
|
||||||
|
/// the scope.
|
||||||
|
///
|
||||||
|
/// The preview is drawn from the *data* -- min/max per pixel column, one strip per signal -- and not
|
||||||
|
/// captured from the window: the plot is a `GtkGLArea`, and a GL area's ink is not in the widget's
|
||||||
|
/// paint tree, so a snapshot of it is empty (`gtk_snapshot_free_to_node()` returns NULL). Drawing it
|
||||||
|
/// here also keeps this class GTK-free, which is what makes it testable without a window.
|
||||||
|
std::string save(const ExportTable& table, const EventRecord& meta,
|
||||||
|
std::size_t keep = kDefaultKeep);
|
||||||
|
|
||||||
|
/// The cached events, newest first: the sidecars, never the sample files, so a listing is cheap
|
||||||
|
/// however large the captures are.
|
||||||
|
std::vector<EventRecord> list() const;
|
||||||
|
|
||||||
|
/// Draws `record`'s preview again from its samples and returns the thumbnail's path, or an empty
|
||||||
|
/// string when the samples cannot be read.
|
||||||
|
///
|
||||||
|
/// This is the repair for a preview that is missing or will not decode -- a write that failed, a
|
||||||
|
/// truncated file, a cache written by an older build. The `.npy` is the event, the `.bmp` is only a
|
||||||
|
/// picture of it, so a lost picture is recoverable and must not be shown as a blank row. It reads
|
||||||
|
/// the samples, so unlike `list()` it is **not** cheap: call it for the one event that needs it.
|
||||||
|
std::string redraw_preview(const EventRecord& record) const;
|
||||||
|
|
||||||
|
/// Deletes one event's samples, sidecar and thumbnail.
|
||||||
|
bool forget(const std::string& stem);
|
||||||
|
|
||||||
|
/// Deletes the oldest events beyond `keep`.
|
||||||
|
void prune(std::size_t keep) const;
|
||||||
|
|
||||||
|
private:
|
||||||
|
/// The base directory every source folder lives under (an absolute path, or empty for a store
|
||||||
|
/// with no source), and the source's own sanitized key: kept so a configuration switch can
|
||||||
|
/// re-derive `dir_` without asking the caller for either again.
|
||||||
|
std::string root_;
|
||||||
|
std::string source_;
|
||||||
|
std::string configuration_;
|
||||||
|
std::string dir_;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The sidecar's text, and back. One `key = value` per line, the project's own TOML subset, so a
|
||||||
|
/// human can read the cache with `cat` -- which is the point of a forensic directory.
|
||||||
|
std::string event_meta_text(const EventRecord& record);
|
||||||
|
bool event_meta_parse(const std::string& text, EventRecord* out);
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -6,8 +6,11 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <bit>
|
#include <bit>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <ctime>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -34,18 +37,29 @@ std::string sanitise(const std::string& name) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The final labels: sanitised, and de-duplicated against each other *and* against the
|
/// `base` carrying a `_2`, `_3` … suffix for the `n`-th copy (`n == 1` is the name itself),
|
||||||
/// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
|
/// cut to `max_len` bytes when one is given -- MDF's name field is 32 bytes wide and the file
|
||||||
/// easily sanitise to the same string.
|
/// is re-read by a reader that keeps only the *first* occurrence of a name, so a cut that made
|
||||||
std::vector<std::string> column_labels(const ExportTable& table) {
|
/// two channels equal would silently lose one.
|
||||||
std::vector<std::string> taken{"time", "stream_time"};
|
std::string numbered(const std::string& base, int n, std::size_t max_len) {
|
||||||
|
const std::string suffix = n == 1 ? std::string() : "_" + std::to_string(n);
|
||||||
|
std::size_t room = base.size();
|
||||||
|
if (max_len != 0) room = max_len > suffix.size() ? max_len - suffix.size() : 0;
|
||||||
|
return base.substr(0, room) + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The final labels: sanitised, and de-duplicated against each other *and* against
|
||||||
|
/// `taken` -- the names the writer already spends -- 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,
|
||||||
|
std::size_t max_len = 0) {
|
||||||
std::vector<std::string> out;
|
std::vector<std::string> out;
|
||||||
out.reserve(table.columns.size());
|
out.reserve(table.columns.size());
|
||||||
for (const ExportTable::Column& col : table.columns) {
|
for (const ExportTable::Column& col : table.columns) {
|
||||||
const std::string base = sanitise(col.name);
|
const std::string base = sanitise(col.name);
|
||||||
std::string label = base;
|
std::string label = numbered(base, 1, max_len);
|
||||||
for (int n = 2; std::find(taken.begin(), taken.end(), label) != taken.end(); ++n) {
|
for (int n = 2; std::find(taken.begin(), taken.end(), label) != taken.end(); ++n) {
|
||||||
label = base + "_" + std::to_string(n);
|
label = numbered(base, n, max_len);
|
||||||
}
|
}
|
||||||
taken.push_back(label);
|
taken.push_back(label);
|
||||||
out.push_back(std::move(label));
|
out.push_back(std::move(label));
|
||||||
@@ -69,6 +83,74 @@ bool opened(const File& out, const std::string& path, std::string& error) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The block lengths of MDF 3.30, from the **reader's** own field lists
|
||||||
|
/// (`asammdf/blocks/v2_v3_constants.py`, 8.8.27): every block is a two-letter id, a 16-bit
|
||||||
|
/// length and then its own fields, and the reader walks the file by those lengths alone, so a
|
||||||
|
/// block written one field too wide is a file whose reader lands in the middle of the next one.
|
||||||
|
/// The widths here are the ones a `bytes(block)` of the reference file measures (the known-good
|
||||||
|
/// 3.30 file `/tmp/ref.mdf` this writer was byte-diffed against).
|
||||||
|
constexpr std::size_t kMdfIdBlock = 64; // "MDF 3.30" and the flags, no id/length
|
||||||
|
constexpr std::size_t kMdfHeader = 208; // 164 common + 44 of the 3.2-era time fields
|
||||||
|
constexpr std::size_t kMdfTxOverhead = 5; // "TX", the u16 length and the terminating NUL
|
||||||
|
constexpr std::size_t kMdfDataGroup = 28; // 4 addresses, the group count, 4 reserved
|
||||||
|
constexpr std::size_t kMdfConversion = 46; // a NONE conversion is the common fields alone
|
||||||
|
constexpr std::size_t kMdfChannel = 228; // the display-name-length CNBLOCK
|
||||||
|
constexpr std::size_t kMdfChannelGroup = 30; // 26 + the sample-reduction address of 3.30
|
||||||
|
constexpr std::size_t kMdfNameBytes = 31; // a CNBLOCK's 32-byte name field, less its NUL
|
||||||
|
|
||||||
|
/// A byte sink for the MDF header blocks, which are a few hundred bytes each and have to be
|
||||||
|
/// laid out field by field. The little-endian `raw()` writes are the same assumption the `.npy`
|
||||||
|
/// writer states at the top of this file, and `static_assert`s there.
|
||||||
|
struct Bytes {
|
||||||
|
std::vector<unsigned char> v;
|
||||||
|
|
||||||
|
void raw(const void* p, std::size_t n) {
|
||||||
|
const auto* b = static_cast<const unsigned char*>(p);
|
||||||
|
v.insert(v.end(), b, b + n);
|
||||||
|
}
|
||||||
|
void u16(std::uint16_t x) {
|
||||||
|
const unsigned char b[2] = {static_cast<unsigned char>(x & 0xFFu),
|
||||||
|
static_cast<unsigned char>((x >> 8) & 0xFFu)};
|
||||||
|
raw(b, sizeof(b));
|
||||||
|
}
|
||||||
|
void i16(std::int16_t x) { u16(static_cast<std::uint16_t>(x)); }
|
||||||
|
void u32(std::uint32_t x) {
|
||||||
|
const unsigned char b[4] = {static_cast<unsigned char>(x & 0xFFu),
|
||||||
|
static_cast<unsigned char>((x >> 8) & 0xFFu),
|
||||||
|
static_cast<unsigned char>((x >> 16) & 0xFFu),
|
||||||
|
static_cast<unsigned char>((x >> 24) & 0xFFu)};
|
||||||
|
raw(b, sizeof(b));
|
||||||
|
}
|
||||||
|
void u64(std::uint64_t x) {
|
||||||
|
u32(static_cast<std::uint32_t>(x & 0xFFFFFFFFu));
|
||||||
|
u32(static_cast<std::uint32_t>(x >> 32));
|
||||||
|
}
|
||||||
|
void f64(double x) { raw(&x, sizeof(x)); }
|
||||||
|
void f32(float x) { raw(&x, sizeof(x)); }
|
||||||
|
void zeros(std::size_t n) { v.insert(v.end(), n, 0); }
|
||||||
|
|
||||||
|
/// A fixed-width text field: the bytes, then NUL padding. MDF's names and units are
|
||||||
|
/// NUL-terminated inside a field of a width the reader is told by the block size, so this is
|
||||||
|
/// how every string in the format is spelled.
|
||||||
|
void text(const std::string& s, std::size_t width) {
|
||||||
|
const std::size_t n = std::min(s.size(), width);
|
||||||
|
raw(s.data(), n);
|
||||||
|
zeros(width - n);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t size() const noexcept { return v.size(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Checked rather than trusted: a field written at the wrong width would not fail here, it
|
||||||
|
/// would produce a file whose reader walks into the middle of the next block. The widths are
|
||||||
|
/// the asammdf 8.8.27 reader's own field lists (`blocks/v2_v3_constants.py`).
|
||||||
|
bool mdf_block_size(const Bytes& block, std::size_t want, const char* what, std::string& error) {
|
||||||
|
if (block.size() == want) return true;
|
||||||
|
error = std::string("internal: the MDF ") + what + " block is " + std::to_string(block.size()) +
|
||||||
|
" bytes, the layout wants " + std::to_string(want);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||||
@@ -97,6 +179,12 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
return static_cast<std::size_t>(c) < infos.size() ? infos[static_cast<std::size_t>(c)].name
|
return static_cast<std::size_t>(c) < infos.size() ? infos[static_cast<std::size_t>(c)].name
|
||||||
: std::string("ch") + std::to_string(c);
|
: std::string("ch") + std::to_string(c);
|
||||||
};
|
};
|
||||||
|
// The unit travels with the column so the MDF writer can put it on the channel; the other
|
||||||
|
// writers ignore it, as they always have.
|
||||||
|
const auto unit_of = [&](int c) {
|
||||||
|
return static_cast<std::size_t>(c) < infos.size() ? infos[static_cast<std::size_t>(c)].unit
|
||||||
|
: std::string();
|
||||||
|
};
|
||||||
|
|
||||||
// What the rows bind to is what gets exported, so the split is the same `DisplayMode`
|
// What the rows bind to is what gets exported, so the split is the same `DisplayMode`
|
||||||
// `RowSet::bind()` switches on: the frozen event only while one is held, the live ring
|
// `RowSet::bind()` switches on: the frozen event only while one is held, the live ring
|
||||||
@@ -106,6 +194,14 @@ 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
|
// 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.
|
// snapshots, so the export is the picture the user is looking at either way.
|
||||||
std::shared_ptr<const EventSnapshot> snap = model.display_snapshot();
|
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) {
|
if (snap != nullptr) {
|
||||||
// The snapshot is immutable and already index-aligned, so the visible window is two
|
// 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.
|
// binary searches over its stamps and the same index range for every channel.
|
||||||
@@ -115,22 +211,21 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
if (hi <= lo) return table;
|
if (hi <= lo) return table;
|
||||||
const std::size_t first = static_cast<std::size_t>(lo - st.begin());
|
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);
|
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.time.resize(n);
|
||||||
table.stream_time.resize(n);
|
table.abs_seconds.resize(n);
|
||||||
for (std::size_t i = 0; i < n; ++i) {
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
table.time[i] = st[first + i];
|
table.time[i] = st[first + i];
|
||||||
table.stream_time[i] = edge + table.time[i];
|
table.abs_seconds[i] = snap->abs_stamps.empty()
|
||||||
|
? abs_base + table.time[i]
|
||||||
|
: snap->abs_stamps[first + i];
|
||||||
}
|
}
|
||||||
for (const int c : chans) {
|
for (const int c : chans) {
|
||||||
const std::size_t ch = static_cast<std::size_t>(c);
|
const std::size_t ch = static_cast<std::size_t>(c);
|
||||||
if (ch >= snap->values.size()) continue;
|
if (ch >= snap->values.size()) continue;
|
||||||
const std::vector<f32>& src = snap->values[ch];
|
const std::vector<f32>& src = snap->values[ch];
|
||||||
if (src.size() < first + n) continue;
|
if (src.size() < first + n) continue;
|
||||||
table.columns.push_back({name_of(c), std::vector<f32>(src.begin() + static_cast<std::ptrdiff_t>(first),
|
table.columns.push_back({name_of(c), unit_of(c),
|
||||||
|
std::vector<f32>(src.begin() + static_cast<std::ptrdiff_t>(first),
|
||||||
src.begin() + static_cast<std::ptrdiff_t>(first + n))});
|
src.begin() + static_cast<std::ptrdiff_t>(first + n))});
|
||||||
}
|
}
|
||||||
return table;
|
return table;
|
||||||
@@ -141,12 +236,12 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
// the same window hold the same samples. The lock is against a structural change
|
// 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.
|
// dropping the rings under the copy, which is what the UI thread has to fear here.
|
||||||
std::lock_guard<std::mutex> lock(model.mutex());
|
std::lock_guard<std::mutex> lock(model.mutex());
|
||||||
// The axis reads `display_epoch + stamp`, so the window has to be put back on the
|
// The axis reads `stamp - stream_at_x0()`, so the window has to be put back on the stream's
|
||||||
// stream's clock before the ring is searched. It is 0 in live mode, but reading it is
|
// clock before the ring is searched -- and that offset is a whole window while streaming now,
|
||||||
// what keeps this correct rather than merely true today.
|
// not the 0 it once was: the live axis is `[-window, 0]` about the newest sample.
|
||||||
const f64 epoch = model.display_epoch();
|
const f64 s0 = model.stream_at_x0();
|
||||||
u64 first = 0, last = 0;
|
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;
|
const u64 n = last - first;
|
||||||
if (n == 0) return table;
|
if (n == 0) return table;
|
||||||
|
|
||||||
@@ -154,17 +249,40 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
std::vector<f64> 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());
|
store.time().copy_from(first, n, stamps.data());
|
||||||
table.time.resize(static_cast<std::size_t>(n));
|
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));
|
||||||
|
std::vector<f64> abs_stamps;
|
||||||
|
if (const plot::data::RingBuffer<f64>* abs_ring = store.abs_time(); abs_ring != nullptr) {
|
||||||
|
abs_stamps.resize(static_cast<std::size_t>(n));
|
||||||
|
abs_ring->copy_from(first, n, abs_stamps.data());
|
||||||
|
}
|
||||||
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
for (std::size_t i = 0; i < stamps.size(); ++i) {
|
||||||
table.stream_time[i] = stamps[i];
|
table.time[i] = stamps[i] - s0;
|
||||||
table.time[i] = epoch + table.stream_time[i];
|
table.abs_seconds[i] = abs_stamps.empty()
|
||||||
|
? abs_base + table.time[i]
|
||||||
|
: model.epoch() + abs_stamps[i];
|
||||||
}
|
}
|
||||||
for (const int c : chans) {
|
for (const int c : chans) {
|
||||||
const std::size_t ch = static_cast<std::size_t>(c);
|
const std::size_t ch = static_cast<std::size_t>(c);
|
||||||
if (ch >= model.channel_count()) continue;
|
if (ch >= model.channel_count()) continue;
|
||||||
std::vector<f32> values(static_cast<std::size_t>(n));
|
std::vector<f32> values(static_cast<std::size_t>(n));
|
||||||
store.values(ch).copy_from(first, n, values.data());
|
store.values(ch).copy_from(first, n, values.data());
|
||||||
table.columns.push_back({name_of(c), std::move(values)});
|
table.columns.push_back({name_of(c), unit_of(c), std::move(values)});
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
ExportTable event_table(ScopeModel& model, const EventSnapshot& snapshot) {
|
||||||
|
ExportTable table;
|
||||||
|
table.time = snapshot.stamps;
|
||||||
|
table.abs_seconds = snapshot.abs_stamps.empty() ? snapshot.stamps : snapshot.abs_stamps;
|
||||||
|
std::lock_guard<std::mutex> lock(model.mutex());
|
||||||
|
const std::vector<ChannelInfo>& channels = model.channels();
|
||||||
|
for (std::size_t c = 0; c < snapshot.values.size() && c < channels.size(); ++c) {
|
||||||
|
ExportTable::Column column;
|
||||||
|
column.name = channels[c].name;
|
||||||
|
column.unit = channels[c].unit;
|
||||||
|
column.values = snapshot.values[c];
|
||||||
|
table.columns.push_back(std::move(column));
|
||||||
}
|
}
|
||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
@@ -174,12 +292,12 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
error = "nothing to export: no signal is plotted in the visible range";
|
error = "nothing to export: no signal is plotted in the visible range";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const std::vector<std::string> labels = column_labels(table);
|
const std::vector<std::string> labels = column_labels(table, {"time", "abs_seconds"});
|
||||||
|
|
||||||
// `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy
|
// `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
|
// only inserts alignment padding when asked to (`align=True`) -- so the records below
|
||||||
// are field widths back to back with no gaps.
|
// 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')";
|
for (const std::string& label : labels) descr += ", ('" + label + "', '<f4')";
|
||||||
descr += "]";
|
descr += "]";
|
||||||
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" +
|
std::string header = "{'descr': " + descr + ", 'fortran_order': False, 'shape': (" +
|
||||||
@@ -211,7 +329,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
std::size_t at = 0;
|
std::size_t at = 0;
|
||||||
std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
|
std::memcpy(record.data() + at, &table.time[i], sizeof(f64));
|
||||||
at += 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);
|
at += sizeof(f64);
|
||||||
for (const ExportTable::Column& col : table.columns) {
|
for (const ExportTable::Column& col : table.columns) {
|
||||||
const f32 v = i < col.values.size() ? col.values[i] : 0.0f;
|
const f32 v = i < col.values.size() ? col.values[i] : 0.0f;
|
||||||
@@ -238,8 +356,8 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
File out(path);
|
File out(path);
|
||||||
if (!opened(out, path, error)) return false;
|
if (!opened(out, path, error)) return false;
|
||||||
|
|
||||||
const std::vector<std::string> labels = column_labels(table);
|
const std::vector<std::string> labels = column_labels(table, {"time", "abs_seconds"});
|
||||||
std::string line = "time,stream_time";
|
std::string line = "time,abs_seconds";
|
||||||
for (const std::string& label : labels) line += "," + label;
|
for (const std::string& label : labels) line += "," + label;
|
||||||
line += "\n";
|
line += "\n";
|
||||||
std::fwrite(line.data(), 1, line.size(), out.f);
|
std::fwrite(line.data(), 1, line.size(), out.f);
|
||||||
@@ -252,7 +370,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
line.clear();
|
line.clear();
|
||||||
std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]);
|
std::snprintf(buf, sizeof(buf), "%.17g", table.time[i]);
|
||||||
line += buf;
|
line += buf;
|
||||||
std::snprintf(buf, sizeof(buf), "%.17g", table.stream_time[i]);
|
std::snprintf(buf, sizeof(buf), "%.17g", table.abs_seconds[i]);
|
||||||
line += ",";
|
line += ",";
|
||||||
line += buf;
|
line += buf;
|
||||||
for (const ExportTable::Column& col : table.columns) {
|
for (const ExportTable::Column& col : table.columns) {
|
||||||
@@ -274,4 +392,256 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool write_mdf(const std::string& path, const ExportTable& table, std::string& error) {
|
||||||
|
if (table.empty()) {
|
||||||
|
error = "nothing to export: no signal is plotted in the visible range";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const std::size_t rows = table.rows();
|
||||||
|
const std::size_t cols = table.columns.size();
|
||||||
|
|
||||||
|
// **The master is the absolute clock, named `time`**, and the axis the plot showed goes out as
|
||||||
|
// an ordinary channel, `time_relative`. The relative column is 0 at a different sample in each
|
||||||
|
// display mode, so a master off it would put a live export and a held event's export on two
|
||||||
|
// timelines -- the one thing the second column exists to avoid -- while `abs_seconds` is the
|
||||||
|
// number the stream itself stamped the samples with. Both carry the unit `s`.
|
||||||
|
const std::vector<std::string> labels =
|
||||||
|
column_labels(table, {"time", "time_relative"}, kMdfNameBytes);
|
||||||
|
const std::size_t channels = cols + 2;
|
||||||
|
const std::size_t record = 2 * sizeof(f64) + cols * sizeof(f32);
|
||||||
|
if (rows > 0xFFFFFFFFull) {
|
||||||
|
error = "too many samples for an MDF 3 file: the channel group counts cycles in 32 bits";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (128 + (cols - 1) * 32 > 0xFFFFu) {
|
||||||
|
error = "too many signals for an MDF 3 record: a channel's start offset is 16 bits";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One channel per column, described once: what the CNBLOCK says about it (where in the record
|
||||||
|
// its samples start, how wide they are, whether it is the master) and what the CCBLOCK says
|
||||||
|
// (its unit). The range is measured from the samples rather than left at zero, so
|
||||||
|
// `range_flag = 1` means what it says.
|
||||||
|
struct Channel {
|
||||||
|
std::string name;
|
||||||
|
std::string unit;
|
||||||
|
std::uint16_t bit_offset;
|
||||||
|
std::uint16_t bit_count;
|
||||||
|
std::uint16_t data_type; // MDF 3: 2 = float, 3 = double, both in the ID block's byte order
|
||||||
|
std::uint16_t type; // 0 = value, 1 = master
|
||||||
|
f64 lo, hi;
|
||||||
|
};
|
||||||
|
const auto range_of = [](const auto& values) {
|
||||||
|
if (values.empty()) return std::pair<f64, f64>{0.0, 0.0};
|
||||||
|
const auto mm = std::minmax_element(values.begin(), values.end());
|
||||||
|
return std::pair<f64, f64>{static_cast<f64>(*mm.first), static_cast<f64>(*mm.second)};
|
||||||
|
};
|
||||||
|
std::vector<Channel> chans;
|
||||||
|
chans.reserve(channels);
|
||||||
|
const auto abs_range = range_of(table.abs_seconds);
|
||||||
|
const auto rel_range = range_of(table.time);
|
||||||
|
chans.push_back({"time", "s", 0, 64, 3, 1, abs_range.first, abs_range.second});
|
||||||
|
chans.push_back({"time_relative", "s", 64, 64, 3, 0, rel_range.first, rel_range.second});
|
||||||
|
for (std::size_t i = 0; i < cols; ++i) {
|
||||||
|
const auto range = range_of(table.columns[i].values);
|
||||||
|
chans.push_back({labels[i], table.columns[i].unit,
|
||||||
|
static_cast<std::uint16_t>(128 + i * 32), 32, 2, 0, range.first, range.second});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every address in the file, from the fixed block lengths above: the writer therefore knows
|
||||||
|
// where it is going before it writes a byte, which is what lets each block carry its links.
|
||||||
|
const std::string comment = "udpscope export: the visible window, " + std::to_string(rows) +
|
||||||
|
" samples x " + std::to_string(cols) + " signals";
|
||||||
|
const std::size_t hd_end = kMdfIdBlock + kMdfHeader;
|
||||||
|
const std::size_t comment_addr = hd_end;
|
||||||
|
const std::size_t data_addr = comment_addr + kMdfTxOverhead + comment.size();
|
||||||
|
const std::size_t dg_addr = data_addr + rows * record;
|
||||||
|
const std::size_t ch_addr = dg_addr + kMdfDataGroup;
|
||||||
|
const std::size_t cg_addr = ch_addr + channels * (kMdfConversion + kMdfChannel);
|
||||||
|
const std::uint64_t total = cg_addr + kMdfChannelGroup;
|
||||||
|
if (total > 0xFFFFFFFFull) {
|
||||||
|
error = "the export is larger than an MDF 3 file can address";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto cc_addr = [&](std::size_t i) {
|
||||||
|
return ch_addr + i * (kMdfConversion + kMdfChannel);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The wall clock the measurement file was written at. `abs_time` is the same instant in
|
||||||
|
// nanoseconds since the epoch, which is what the 3.2-era header field asks for.
|
||||||
|
const std::time_t now = std::time(nullptr);
|
||||||
|
std::tm tm{};
|
||||||
|
localtime_r(&now, &tm);
|
||||||
|
char date[11] = {};
|
||||||
|
char clock[9] = {};
|
||||||
|
std::strftime(date, sizeof(date), "%d:%m:%Y", &tm);
|
||||||
|
std::strftime(clock, sizeof(clock), "%H:%M:%S", &tm);
|
||||||
|
|
||||||
|
Bytes head;
|
||||||
|
// Identification block: the version string, who wrote the file, and the byte order every
|
||||||
|
// sample in it is in.
|
||||||
|
head.text("MDF ", 8);
|
||||||
|
head.text("3.30", 8);
|
||||||
|
head.text("udpscope", 8);
|
||||||
|
head.u16(0); // byte order: Intel, ahead of every value and the float format below
|
||||||
|
head.u16(0); // float format: IEEE
|
||||||
|
head.u16(330); // MDF version, digits without the separator
|
||||||
|
head.u16(0); // code page: latin-1
|
||||||
|
head.zeros(2); // reserved
|
||||||
|
head.zeros(26); // reserved
|
||||||
|
head.u16(0); // unfinalised standard flags
|
||||||
|
head.u16(0); // unfinalised custom flags
|
||||||
|
if (!mdf_block_size(head, kMdfIdBlock, "identification", error)) return false;
|
||||||
|
|
||||||
|
// Header block, which points at the data group and at the comment that follows it.
|
||||||
|
head.raw("HD", 2);
|
||||||
|
head.u16(static_cast<std::uint16_t>(kMdfHeader));
|
||||||
|
head.u32(static_cast<std::uint32_t>(dg_addr)); // first data group
|
||||||
|
head.u32(static_cast<std::uint32_t>(comment_addr));
|
||||||
|
head.u32(0); // program block: nothing wrote this but us
|
||||||
|
head.u16(1); // data groups
|
||||||
|
head.text(date, 10);
|
||||||
|
head.text(clock, 8);
|
||||||
|
head.text("", 32); // author
|
||||||
|
head.text("", 32); // department
|
||||||
|
head.text("", 32); // project
|
||||||
|
head.text("", 32); // subject
|
||||||
|
head.u64(static_cast<std::uint64_t>(now) * 1000000000ull);
|
||||||
|
head.i16(static_cast<std::int16_t>(tm.tm_gmtoff / 3600));
|
||||||
|
head.u16(0); // time quality
|
||||||
|
head.text("Local PC Reference Time", 32);
|
||||||
|
if (!mdf_block_size(head, hd_end, "header", error)) return false;
|
||||||
|
|
||||||
|
// The comment, in the one text block the file carries: plain words rather than the XML a
|
||||||
|
// writer that also *edits* files keeps its structured properties in.
|
||||||
|
head.raw("TX", 2);
|
||||||
|
head.u16(static_cast<std::uint16_t>(comment.size() + kMdfTxOverhead));
|
||||||
|
head.raw(comment.data(), comment.size());
|
||||||
|
head.zeros(1);
|
||||||
|
|
||||||
|
Bytes tail;
|
||||||
|
// Data group: one channel group, whose channels are chained, and the address of the samples.
|
||||||
|
tail.raw("DG", 2);
|
||||||
|
tail.u16(static_cast<std::uint16_t>(kMdfDataGroup));
|
||||||
|
tail.u32(0); // next data group
|
||||||
|
tail.u32(static_cast<std::uint32_t>(cg_addr));
|
||||||
|
tail.u32(0); // trigger block
|
||||||
|
tail.u32(static_cast<std::uint32_t>(data_addr));
|
||||||
|
tail.u16(1); // channel groups
|
||||||
|
tail.u16(0); // record IDs: the group is sorted
|
||||||
|
tail.zeros(4); // reserved
|
||||||
|
if (!mdf_block_size(tail, kMdfDataGroup, "data group", error)) return false;
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < channels; ++i) {
|
||||||
|
const Channel& c = chans[i];
|
||||||
|
// Conversion block: the unit lives here and nowhere else in MDF 3, which is where ScryLab
|
||||||
|
// reads a signal's unit from. NONE because the samples are already the physical values --
|
||||||
|
// the table was collected as `f32`, so a conversion would only be a second copy of it.
|
||||||
|
Bytes cc;
|
||||||
|
cc.raw("CC", 2);
|
||||||
|
cc.u16(static_cast<std::uint16_t>(kMdfConversion));
|
||||||
|
cc.u16(1); // range_flag: the range below is measured, so it is valid
|
||||||
|
cc.f64(c.lo);
|
||||||
|
cc.f64(c.hi);
|
||||||
|
cc.text(c.unit, 20);
|
||||||
|
cc.u16(0xFFFFu);
|
||||||
|
cc.u16(0); // referenced parameters
|
||||||
|
if (!mdf_block_size(cc, kMdfConversion, "conversion", error)) return false;
|
||||||
|
|
||||||
|
// Channel block: the name ScryLab turns into the signal name, where this channel's samples
|
||||||
|
// sit in a record, and the link to the next channel.
|
||||||
|
Bytes cn;
|
||||||
|
cn.raw("CN", 2);
|
||||||
|
cn.u16(static_cast<std::uint16_t>(kMdfChannel));
|
||||||
|
cn.u32(i + 1 < channels ? static_cast<std::uint32_t>(cc_addr(i + 1) + kMdfConversion) : 0);
|
||||||
|
cn.u32(static_cast<std::uint32_t>(cc_addr(i)));
|
||||||
|
cn.u32(0); // source: the stream does not say what produced a sample
|
||||||
|
cn.u32(0); // dependency block
|
||||||
|
cn.u32(0); // comment
|
||||||
|
cn.u16(c.type);
|
||||||
|
cn.text(c.name, 32);
|
||||||
|
cn.zeros(128); // description
|
||||||
|
cn.u16(c.bit_offset);
|
||||||
|
cn.u16(c.bit_count);
|
||||||
|
cn.u16(c.data_type);
|
||||||
|
cn.u16(1); // range_flag
|
||||||
|
cn.f64(c.lo);
|
||||||
|
cn.f64(c.hi);
|
||||||
|
cn.f64(0.0); // sampling rate: only a virtual time channel carries one
|
||||||
|
cn.u32(0); // long name
|
||||||
|
cn.u32(0); // display name
|
||||||
|
cn.u16(0); // additional byte offset
|
||||||
|
if (!mdf_block_size(cn, kMdfChannel, "channel", error)) return false;
|
||||||
|
|
||||||
|
tail.v.insert(tail.v.end(), cc.v.begin(), cc.v.end());
|
||||||
|
tail.v.insert(tail.v.end(), cn.v.begin(), cn.v.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel group, last: it names the first channel, the record size and the cycle count from
|
||||||
|
// which a reader derives the size of the sample block at `data_addr`.
|
||||||
|
tail.raw("CG", 2);
|
||||||
|
tail.u16(static_cast<std::uint16_t>(kMdfChannelGroup));
|
||||||
|
tail.u32(0); // next channel group
|
||||||
|
tail.u32(static_cast<std::uint32_t>(ch_addr + kMdfConversion));
|
||||||
|
tail.u32(0); // comment
|
||||||
|
tail.u16(1); // record ID
|
||||||
|
tail.u16(static_cast<std::uint16_t>(channels));
|
||||||
|
tail.u16(static_cast<std::uint16_t>(record));
|
||||||
|
tail.u32(static_cast<std::uint32_t>(rows));
|
||||||
|
tail.zeros(4); // sample reduction address
|
||||||
|
if (!mdf_block_size(tail, kMdfDataGroup + channels * (kMdfConversion + kMdfChannel) +
|
||||||
|
kMdfChannelGroup,
|
||||||
|
"data group and channels", error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
File out(path);
|
||||||
|
if (!opened(out, path, error)) return false;
|
||||||
|
const auto put = [&](const Bytes& bytes) {
|
||||||
|
return bytes.v.empty() || std::fwrite(bytes.v.data(), 1, bytes.v.size(), out.f) == bytes.v.size();
|
||||||
|
};
|
||||||
|
if (!put(head)) {
|
||||||
|
error = "short write to '" + path + "': " + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// One record at a time, in the channel order the blocks above declare, so a full ring is not
|
||||||
|
// held twice -- the same shape the `.npy` writer streams its records in.
|
||||||
|
std::vector<unsigned char> rec(record);
|
||||||
|
for (std::size_t i = 0; i < rows; ++i) {
|
||||||
|
std::size_t at = 0;
|
||||||
|
std::memcpy(rec.data() + at, &table.abs_seconds[i], sizeof(f64));
|
||||||
|
at += sizeof(f64);
|
||||||
|
std::memcpy(rec.data() + at, &table.time[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;
|
||||||
|
std::memcpy(rec.data() + at, &v, sizeof(f32));
|
||||||
|
at += sizeof(f32);
|
||||||
|
}
|
||||||
|
if (std::fwrite(rec.data(), 1, rec.size(), out.f) != rec.size()) {
|
||||||
|
error = "short write to '" + path + "': " + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!put(tail)) {
|
||||||
|
error = "short write to '" + path + "': " + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (std::fflush(out.f) != 0) {
|
||||||
|
error = "cannot flush '" + path + "': " + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool write_export(const std::string& path, const ExportTable& table, std::string& error) {
|
||||||
|
const auto ends_with = [&path](const char* ext) {
|
||||||
|
const std::size_t n = std::strlen(ext);
|
||||||
|
return path.size() >= n && path.compare(path.size() - n, n, ext) == 0;
|
||||||
|
};
|
||||||
|
if (ends_with(".csv")) return write_csv(path, table, error);
|
||||||
|
if (ends_with(".mf3") || ends_with(".mdf")) return write_mdf(path, table, error);
|
||||||
|
return write_npy(path, table, error);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
@@ -21,16 +21,30 @@ namespace udpscope {
|
|||||||
struct ExportTable {
|
struct ExportTable {
|
||||||
struct Column {
|
struct Column {
|
||||||
std::string name; ///< the legend name, sanitised for a column label
|
std::string name; ///< the legend name, sanitised for a column label
|
||||||
|
/// The signal's unit exactly as the CONFIG carried it, the empty string when it
|
||||||
|
/// carried none. The `.npy`/`.csv` writers do not spell it out (their readers get the
|
||||||
|
/// names and the numbers); MDF does, because the unit *is* part of what a channel is
|
||||||
|
/// there, and it is the only thing ScryLab reads a signal's unit from.
|
||||||
|
std::string unit;
|
||||||
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
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,
|
/// The x the plot shows and the crosshair reads, which is **relative** in every mode: seconds
|
||||||
/// edge-relative seconds in `[-pre, +post]` while an event is held.
|
/// 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;
|
std::vector<f64> time;
|
||||||
/// The stream's own clock, so two exports taken in different modes line up. Equal to
|
/// The stream's own absolute clock -- the number the time signal carried, which is what the
|
||||||
/// `time` in live mode; `time + trigger_time` while an event is held, which is the one
|
/// source stamped the samples with -- so two exports taken in different modes, or from
|
||||||
/// case where the visible x is not the stream's.
|
/// different runs, line up on one timeline.
|
||||||
std::vector<f64> stream_time;
|
///
|
||||||
|
/// `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::vector<Column> columns;
|
||||||
|
|
||||||
std::size_t rows() const noexcept { return time.size(); }
|
std::size_t rows() const noexcept { return time.size(); }
|
||||||
@@ -45,7 +59,13 @@ struct ExportTable {
|
|||||||
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
||||||
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
||||||
|
|
||||||
/// Writes a NumPy `.npy` file holding one structured array: `time` and `stream_time` as
|
/// The held event as a table, built from the **snapshot** rather than from the rows: a cache of the
|
||||||
|
/// acquisition, every channel the event holds, independent of which traces a row happens to show
|
||||||
|
/// when the latch lands. The channel names are the model's, in channel order -- the same order the
|
||||||
|
/// columns come out in, so `--open` restores the same signals.
|
||||||
|
ExportTable event_table(ScopeModel& model, const EventSnapshot& snapshot);
|
||||||
|
|
||||||
|
/// 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
|
/// `<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.
|
/// about half the size of an all-`f8` one and invents no precision.
|
||||||
///
|
///
|
||||||
@@ -56,4 +76,27 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
/// enough digits to round-trip each column's own type and no more.
|
/// enough digits to round-trip each column's own type and no more.
|
||||||
bool write_csv(const std::string& path, const ExportTable& table, std::string& error);
|
bool write_csv(const std::string& path, const ExportTable& table, std::string& error);
|
||||||
|
|
||||||
|
/// Writes the same table as **MDF 3.30** (`.mf3`/`.mdf`), byte for byte by hand -- there is no
|
||||||
|
/// writer library here and MF4 (HDF5) is the one format this app will not link.
|
||||||
|
///
|
||||||
|
/// One channel group, all channels record-interleaved, master first, which is the shape the
|
||||||
|
/// [ScryLab data sources](https://docs.scrylab.de/docs/data-sources/) contract asks for: *every
|
||||||
|
/// data channel becomes a signal* (the CNBLOCK's `short_name`), *the master channel of each
|
||||||
|
/// group is the x axis* (our master is `abs_seconds`, named `time`), and *the unit is taken from
|
||||||
|
/// the channel metadata* (MDF 3 keeps it in the channel's conversion block, the CCBLOCK). A
|
||||||
|
/// second occurrence of a name is dropped by that reader, so the labels are de-duplicated and cut
|
||||||
|
/// to the 31 bytes a CNBLOCK's name field holds.
|
||||||
|
///
|
||||||
|
/// The master is the **absolute** clock and the axis the plot showed is an ordinary channel,
|
||||||
|
/// `time_relative`: the relative column is `0` at a different sample in every mode, so a master
|
||||||
|
/// off it would put two exports from different modes on two timelines, while `abs_seconds` is the
|
||||||
|
/// number the stream itself stamped the samples with. Both carry the unit `s`.
|
||||||
|
bool write_mdf(const std::string& path, const ExportTable& table, std::string& error);
|
||||||
|
|
||||||
|
/// Writes `table` with the writer **`path`'s extension** names: `.csv` -> `write_csv`,
|
||||||
|
/// `.mf3`/`.mdf` -> `write_mdf`, anything else -> `write_npy` (the default, as the save dialog's
|
||||||
|
/// default filter is). The export dialog and the self-check's export probe both come through
|
||||||
|
/// here, so the two cannot offer different formats.
|
||||||
|
bool write_export(const std::string& path, const ExportTable& table, std::string& error);
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
<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 time you read on screen is a timestamp 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.
|
|
||||||
|
|
||||||
<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.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<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.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<b>Signals and calibration</b>
|
|
||||||
|
|
||||||
The source's CONFIG message describes its signals: a name, a unit and a type. udpscope turns
|
|
||||||
each enabled signal into a <i>channel</i> — a ring of values beside the shared time ring —
|
|
||||||
and a channel is what a trace plots. Calibration is a scale and an offset applied at ingest.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
The side panel (<tt>s</tt>) lists every signal with a switch. The <b>Signal list</b> item in
|
|
||||||
the overflow menu opens the editor where the unit and the calibration live.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
|
|
||||||
nobody acquires can never fire, so arming a trigger enables its signal for you.
|
|
||||||
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
|
|
||||||
history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
|
|
||||||
for a second after one of those is the ring refilling, not a fault.
|
|
||||||
• The stream's first signal is usually its <i>timestamp</i>, and that one is never acquired.
|
|
||||||
It cannot be plotted, measured or used as a math source, and udpscope refuses to put it in
|
|
||||||
a row rather than binding a trace that would read nothing for ever.
|
|
||||||
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
|
|
||||||
is one colour and the side panel's chip identifies it unambiguously. A signal on no row
|
|
||||||
gets a grey chip rather than none, so the panel's rows keep their height.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
A source declaring <tt>time</tt>, <tt>sine</tt>, <tt>ramp</tt> and <tt>noise</tt> yields three
|
|
||||||
plottable signals. Enable all three and the exit dump reads <tt>3 signals, 3 channels</tt>;
|
|
||||||
<tt>time</tt> is not among them.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<b>Rows, layout and drag & drop</b>
|
|
||||||
|
|
||||||
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
|
|
||||||
time axis and one left gutter, so their y labels line up and their windows cannot drift apart.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
Drag a signal's chip out of the side panel (<tt>s</tt>) and drop it on a row to add a trace.
|
|
||||||
Press <tt>l</tt>, or pick <b>Layout editor</b> from the overflow menu, to add and remove rows:
|
|
||||||
a <tt>×</tt> appears at the top left of each row and a <tt>+</tt> on each boundary between
|
|
||||||
rows, including above the first and below the last. The editor's own button in the top-right
|
|
||||||
corner leaves it.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
|
|
||||||
traces already on a row the drop is refused.
|
|
||||||
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
|
|
||||||
puts what row 1 plotted onto row 2 and leaves the new row empty — the rows are not
|
|
||||||
renumbered under you.
|
|
||||||
• A trace is remembered by the signal's <i>name</i>, not by its channel number, because a new
|
|
||||||
CONFIG or an enable renumbers the channels and an index would silently re-point the trace.
|
|
||||||
• Adding or removing a row rebuilds the figure, which is cheap, but it also re-deals every
|
|
||||||
trace. A frozen trigger event survives the rebuild: the pinned window is restated rather
|
|
||||||
than re-framed on the live edge.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
With three traces on row 1, pressing <tt>l</tt> shows 3 buttons (one <tt>×</tt>, two
|
|
||||||
<tt>+</tt>). Clicking the <tt>+</tt> above row 1 gives 2 rows and 5 buttons, with the three
|
|
||||||
traces now on row 2 — the layout probe reports exactly this as
|
|
||||||
<tt>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</tt>.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
<b>Time window</b>
|
|
||||||
|
|
||||||
The window is how much time the plot shows. Every row shares one window: the figure has a
|
|
||||||
single x link group, so the rows cannot drift apart. The trigger's event span follows it, and
|
|
||||||
the pre-trigger share is a share of <i>this</i> window.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
The header bar's window button opens a popover: type a number, pick a unit under it, or step a
|
|
||||||
decade with the arrows. The button's own label is a reading of the current window in a natural
|
|
||||||
unit, width-locked so a change of window cannot widen the header bar.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• A window too long to store at the wire rate is <i>decimated, not refused</i>. The shared clock
|
|
||||||
takes a power-of-two stride, and each group of samples is <b>averaged</b> rather than strided,
|
|
||||||
so a tone above the stored Nyquist is attenuated instead of aliasing to a false low frequency.
|
|
||||||
• The stamp stored for a group is the group's <i>mean</i>, which is where the averaging puts the
|
|
||||||
value. Stamping at the end of a group would shift every trace half a stored sample late.
|
|
||||||
• Moving the window across a decimation boundary rebuilds the buffer and drops the history.
|
|
||||||
Walking it inside one decade is free.
|
|
||||||
• The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge
|
|
||||||
found on a sample that is not in the buffer could not be cut out of it.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
1 MS/s with 200 MB of buffer. A 2 s window stores every sample (<tt>time x1</tt>). Widen it to
|
|
||||||
600 s and the stride becomes <tt>x16</tt>: 625 S/s stored, anti-aliased at 312.5 Hz. A 2 kHz
|
|
||||||
sine seen through that window reads <tt>y=[-0.059, 0.079]</tt> — attenuated to about 6%, not
|
|
||||||
folded down to a full-amplitude 125 Hz one.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<b>Navigating the plot</b>
|
|
||||||
|
|
||||||
The plot pans and zooms with the mouse and with the keyboard. Every gesture acts on the row the
|
|
||||||
pointer is over, and because the rows share one time axis, an x change moves all of them.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
|
|
||||||
<b>Drag</b> pans · <b>Ctrl+drag</b> is a box zoom on x · <b>Alt+drag</b> a box zoom on y.
|
|
||||||
<b>+</b> and <b>-</b> zoom both axes; <b>Ctrl</b> with them restricts to x, <b>Alt</b> to y.
|
|
||||||
<b>Arrow keys</b> pan, <b>f</b> fits, <b>Escape</b> cancels a gesture in progress.
|
|
||||||
The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• The keyboard zoom's modifiers are <i>not</i> the wheel's, and that is deliberate: on common
|
|
||||||
layouts <tt>+</tt> <i>is</i> shift+<tt>=</tt>, so a bare zoom-in already arrives with Shift
|
|
||||||
set and Shift could not mean anything. Ctrl/Alt mirror the box-zoom drags instead.
|
|
||||||
• A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is
|
|
||||||
held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not
|
|
||||||
outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data.
|
|
||||||
• A live view is clamped to the window you asked for, not to what the ring happens to hold.
|
|
||||||
That is why a buffer that has only just started filling leaves its left-hand side empty
|
|
||||||
instead of squeezing the window down to the first few samples.
|
|
||||||
• Zooming hard into a held capture is exact. The renderer finds the visible samples by binary
|
|
||||||
search over the real timestamps, never over a nominal sample grid, so a capture whose clock
|
|
||||||
wandered does not drift away from its axis as you zoom.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
Pause a live view, then press <tt>Ctrl++</tt>: x narrows and y does not move. Press
|
|
||||||
<tt>Alt++</tt>: y narrows and x does not. Eight doublings into a held 2 s capture, the trace
|
|
||||||
still reaches both margins — the zoom probe reports <tt>gap_px</tt> and <tt>end_px</tt> within
|
|
||||||
a pixel or two at every step.
|
|
||||||