Compare commits
377
Commits
No files matched your search
@@ -1 +1,2 @@
|
|||||||
build/
|
build/
|
||||||
|
__pycache__/
|
||||||
+253
-4
@@ -1,5 +1,5 @@
|
|||||||
cmake_minimum_required(VERSION 3.24)
|
cmake_minimum_required(VERSION 3.24)
|
||||||
project(udpscope LANGUAGES C CXX)
|
project(udpscope VERSION 0.1.0 LANGUAGES C CXX)
|
||||||
|
|
||||||
set(CMAKE_CXX_STANDARD 20)
|
set(CMAKE_CXX_STANDARD 20)
|
||||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||||
@@ -11,6 +11,17 @@ 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 ---------------------------------------
|
||||||
|
# GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and libadwaita's
|
||||||
|
# `AdwShortcutsDialog` (1.8) is its replacement, so the overview is built from the
|
||||||
|
# platform widget rather than from a hand-rolled grid. This is the only thing libadwaita
|
||||||
|
# is used for; `adw_init()` is called for it, which also means the app picks up
|
||||||
|
# libadwaita's stylesheet.
|
||||||
|
find_package(PkgConfig REQUIRED)
|
||||||
|
pkg_check_modules(ADWAITA REQUIRED IMPORTED_TARGET libadwaita-1>=1.8)
|
||||||
|
|
||||||
# --- deps/plotter: built from the submodule, GTK4 adapter only ----------------
|
# --- deps/plotter: built from the submodule, GTK4 adapter only ----------------
|
||||||
set(PLOT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
set(PLOT_BUILD_TESTS OFF CACHE BOOL "" FORCE)
|
||||||
@@ -30,7 +41,7 @@ if(CMAKE_C_COMPILER_ID MATCHES "GNU|Clang")
|
|||||||
target_compile_options(udpsclient PRIVATE -Wall -Wextra)
|
target_compile_options(udpsclient PRIVATE -Wall -Wextra)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
# --- icons: four symbolic SVGs compiled into the binary -----------------------
|
# --- icons: six SVGs compiled into the binary --------------------------------
|
||||||
# There is no stock icon for an oscilloscope trigger, a pair of cursors or a measurement
|
# There is no stock icon for an oscilloscope trigger, a pair of cursors or a measurement
|
||||||
# ruler, so this app draws its own. They go in a GResource rather than beside the binary
|
# ruler, so this app draws its own. They go in a GResource rather than beside the binary
|
||||||
# because udpscope is run straight out of the build tree and has no install step: a
|
# because udpscope is run straight out of the build tree and has no install step: a
|
||||||
@@ -39,7 +50,8 @@ endif()
|
|||||||
find_program(GLIB_COMPILE_RESOURCES NAMES glib-compile-resources REQUIRED)
|
find_program(GLIB_COMPILE_RESOURCES NAMES glib-compile-resources REQUIRED)
|
||||||
set(UDPSCOPE_ICON_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/icons)
|
set(UDPSCOPE_ICON_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/icons)
|
||||||
file(GLOB UDPSCOPE_ICON_FILES CONFIGURE_DEPENDS
|
file(GLOB UDPSCOPE_ICON_FILES CONFIGURE_DEPENDS
|
||||||
${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg)
|
${UDPSCOPE_ICON_DIR}/scalable/actions/*.svg
|
||||||
|
${UDPSCOPE_ICON_DIR}/scalable/apps/*.svg)
|
||||||
add_custom_command(
|
add_custom_command(
|
||||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
||||||
COMMAND ${GLIB_COMPILE_RESOURCES}
|
COMMAND ${GLIB_COMPILE_RESOURCES}
|
||||||
@@ -51,25 +63,262 @@ add_custom_command(
|
|||||||
COMMENT "Compiling udpscope icon resources"
|
COMMENT "Compiling udpscope icon resources"
|
||||||
VERBATIM)
|
VERBATIM)
|
||||||
|
|
||||||
|
# --- the manual: a Mallard help set, installed for the `help:` URI ------------
|
||||||
|
# The manual is no longer a GResource compiled into the binary. It is a Mallard set that the
|
||||||
|
# desktop help viewer (Yelp on GNOME) reads off disk, because that is the only way `F1` can
|
||||||
|
# open a real, searchable, translatable document and the only way the freedesktop Help System
|
||||||
|
# Specification's `help:` URI resolves it. The user documentation is then *files*, checked and
|
||||||
|
# installed rather than compiled in.
|
||||||
|
#
|
||||||
|
# The document identifier -- the `<name>` in `help:<name>` -- is derived from the desktop
|
||||||
|
# file's own basename (its desktop-file id), not written out twice: the same string is the
|
||||||
|
# `.desktop` file, the GApplication id and the installed directory. The specification installs
|
||||||
|
# a document at `<datadir>/help/<lang>/<document>/` and resolves `help:<document>` to the first
|
||||||
|
# match under $XDG_DATA_HOME/$XDG_DATA_DIRS, so the directory name *is* the identifier.
|
||||||
|
# NAME_WE is not used: it strips the *longest* extension, so it would cut `org.udpscope.app`
|
||||||
|
# down to `org`. Strip the two suffixes this one file actually carries, in order.
|
||||||
|
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)
|
||||||
|
|
||||||
|
# `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 -------
|
||||||
|
# Written at configure time so add_executable can name the file, and again on every
|
||||||
|
# build so the commit and the timestamp belong to the binary that is running rather
|
||||||
|
# than to whenever the tree was last configured.
|
||||||
|
set(UDPSCOPE_BUILD_INFO ${CMAKE_CURRENT_BINARY_DIR}/build_info.cpp)
|
||||||
|
set(UDPSCOPE_BUILD_INFO_ARGS
|
||||||
|
-DSRC=${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
|
-DOUT=${UDPSCOPE_BUILD_INFO}
|
||||||
|
-DVERSION=${PROJECT_VERSION}
|
||||||
|
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/write_build_info.cmake)
|
||||||
|
execute_process(COMMAND ${CMAKE_COMMAND} ${UDPSCOPE_BUILD_INFO_ARGS})
|
||||||
|
add_custom_target(udpscope_build_info
|
||||||
|
BYPRODUCTS ${UDPSCOPE_BUILD_INFO}
|
||||||
|
COMMAND ${CMAKE_COMMAND} ${UDPSCOPE_BUILD_INFO_ARGS}
|
||||||
|
COMMENT "Writing udpscope build info"
|
||||||
|
VERBATIM)
|
||||||
|
set_source_files_properties(${UDPSCOPE_BUILD_INFO} PROPERTIES GENERATED TRUE)
|
||||||
|
|
||||||
# --- the application ----------------------------------------------------------
|
# --- the application ----------------------------------------------------------
|
||||||
add_executable(udpscope
|
add_executable(udpscope
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||||
|
${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/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/measure.cpp
|
src/measure.cpp
|
||||||
src/math_channel.cpp
|
src/math_channel.cpp
|
||||||
|
src/status_text.cpp
|
||||||
|
src/shortcuts.cpp
|
||||||
|
src/help_window.cpp
|
||||||
|
src/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)
|
||||||
target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads)
|
# 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
|
||||||
|
PkgConfig::ADWAITA)
|
||||||
|
add_dependencies(udpscope udpscope_build_info)
|
||||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||||
target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic)
|
target_compile_options(udpscope PRIVATE -Wall -Wextra -Wpedantic)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# --- the desktop entry: opt-in, never part of `cmake --build` ------------------
|
||||||
|
# `Exec` is the built binary's absolute path in the build tree, so the entry always launches
|
||||||
|
# exactly the binary that was last built -- and moving or deleting the build directory breaks
|
||||||
|
# it. That is the price of having no install step, and it is the right one here: an install()
|
||||||
|
# rule would create a second copy of the app that can diverge from the build tree, and a
|
||||||
|
# documented `cp` would need the binary on PATH, which it is not.
|
||||||
|
#
|
||||||
|
# configure_file() cannot evaluate a generator expression and file(GENERATE) cannot substitute
|
||||||
|
# an @VAR@, so the two run in sequence: the first writes the genex out literally, the second
|
||||||
|
# evaluates it. This is the documented idiom.
|
||||||
|
set(UDPSCOPE_EXEC "$<TARGET_FILE:udpscope>")
|
||||||
|
configure_file(${CMAKE_CURRENT_SOURCE_DIR}/data/org.udpscope.app.desktop.in
|
||||||
|
${CMAKE_CURRENT_BINARY_DIR}/desktop-entry.in @ONLY)
|
||||||
|
file(GENERATE OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/org.udpscope.app.desktop
|
||||||
|
INPUT ${CMAKE_CURRENT_BINARY_DIR}/desktop-entry.in)
|
||||||
|
|
||||||
|
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected.
|
||||||
|
add_custom_target(desktop-entry
|
||||||
|
COMMAND ${CMAKE_COMMAND}
|
||||||
|
-DENTRY=${CMAKE_CURRENT_BINARY_DIR}/org.udpscope.app.desktop
|
||||||
|
-DICON=${UDPSCOPE_ICON_DIR}/scalable/apps/org.udpscope.app.svg
|
||||||
|
-DSYMBOLIC=${UDPSCOPE_ICON_DIR}/scalable/apps/org.udpscope.app-symbolic.svg
|
||||||
|
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/install_desktop_entry.cmake
|
||||||
|
COMMENT "Installing the udpscope desktop entry into XDG_DATA_HOME"
|
||||||
|
VERBATIM)
|
||||||
|
add_dependencies(desktop-entry udpscope)
|
||||||
@@ -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,69 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
#
|
||||||
|
# The `desktop-entry` target's body. Copies the generated entry and the two app icons into
|
||||||
|
# $XDG_DATA_HOME, validates the entry, refreshes the caches that make the shell notice, and
|
||||||
|
# prints every path it wrote.
|
||||||
|
#
|
||||||
|
# This runs only when the target is named. The project deliberately has no install step -- that
|
||||||
|
# is the stated reason its icons are a GResource -- and a target that does nothing until asked
|
||||||
|
# preserves that while still producing a working entry.
|
||||||
|
#
|
||||||
|
# Expects -DENTRY=, -DICON= and -DSYMBOLIC= on the command line.
|
||||||
|
|
||||||
|
if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "")
|
||||||
|
set(data_home "$ENV{XDG_DATA_HOME}")
|
||||||
|
else()
|
||||||
|
set(data_home "$ENV{HOME}/.local/share")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Fail visibly if both fallback paths are unusable. The script needs a directory to install into;
|
||||||
|
# a missing $HOME in a sandboxed CI environment should not silently install to the filesystem root.
|
||||||
|
if(NOT DEFINED ENV{XDG_DATA_HOME} OR "$ENV{XDG_DATA_HOME}" STREQUAL "")
|
||||||
|
if(NOT DEFINED ENV{HOME} OR "$ENV{HOME}" STREQUAL "")
|
||||||
|
message(FATAL_ERROR "XDG_DATA_HOME and HOME are both unset or empty; cannot determine install directory")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(apps_dir "${data_home}/applications")
|
||||||
|
set(icon_dir "${data_home}/icons/hicolor/scalable/apps")
|
||||||
|
set(sym_dir "${data_home}/icons/hicolor/symbolic/apps")
|
||||||
|
|
||||||
|
# The icons are copied from the source tree rather than extracted from the GResource: the same
|
||||||
|
# two files serve both the in-binary resource and the installed theme, so there is one copy to
|
||||||
|
# edit. `file(COPY)` creates the directories.
|
||||||
|
file(COPY "${ENTRY}" DESTINATION "${apps_dir}")
|
||||||
|
file(COPY "${ICON}" DESTINATION "${icon_dir}")
|
||||||
|
file(COPY "${SYMBOLIC}" DESTINATION "${sym_dir}")
|
||||||
|
|
||||||
|
get_filename_component(entry_name "${ENTRY}" NAME)
|
||||||
|
get_filename_component(icon_name "${ICON}" NAME)
|
||||||
|
get_filename_component(sym_name "${SYMBOLIC}" NAME)
|
||||||
|
|
||||||
|
# Validated here rather than left to the shell, which ignores a malformed entry in silence: the
|
||||||
|
# failure belongs at the moment the entry is asked for.
|
||||||
|
find_program(DESKTOP_FILE_VALIDATE desktop-file-validate)
|
||||||
|
if(DESKTOP_FILE_VALIDATE)
|
||||||
|
execute_process(COMMAND "${DESKTOP_FILE_VALIDATE}" "${apps_dir}/${entry_name}"
|
||||||
|
RESULT_VARIABLE validate_result)
|
||||||
|
if(NOT validate_result EQUAL 0)
|
||||||
|
message(FATAL_ERROR "desktop-file-validate rejected ${apps_dir}/${entry_name}")
|
||||||
|
endif()
|
||||||
|
message(STATUS "desktop-file-validate: ok")
|
||||||
|
else()
|
||||||
|
message(STATUS "desktop-file-validate: not on PATH, entry not validated")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
find_program(UPDATE_DESKTOP_DATABASE update-desktop-database)
|
||||||
|
if(UPDATE_DESKTOP_DATABASE)
|
||||||
|
execute_process(COMMAND "${UPDATE_DESKTOP_DATABASE}" "${apps_dir}")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
find_program(GTK4_UPDATE_ICON_CACHE gtk4-update-icon-cache)
|
||||||
|
if(GTK4_UPDATE_ICON_CACHE)
|
||||||
|
execute_process(COMMAND "${GTK4_UPDATE_ICON_CACHE}" -f -t "${data_home}/icons/hicolor")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Printed because there is no uninstall target: undoing this is these three paths.
|
||||||
|
message(STATUS "wrote ${apps_dir}/${entry_name}")
|
||||||
|
message(STATUS "wrote ${icon_dir}/${icon_name}")
|
||||||
|
message(STATUS "wrote ${sym_dir}/${sym_name}")
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
#
|
||||||
|
# Writes ${OUT} with the three strings src/build_info.hpp declares. Run both at
|
||||||
|
# configure time (so the file exists when add_executable names it) and on every
|
||||||
|
# build (so the commit and the timestamp are the ones that produced the binary).
|
||||||
|
#
|
||||||
|
# Expects: SRC (the source tree), OUT (the .cpp to write), VERSION.
|
||||||
|
|
||||||
|
set(COMMIT "unknown")
|
||||||
|
find_package(Git QUIET)
|
||||||
|
if(GIT_FOUND AND EXISTS "${SRC}/.git")
|
||||||
|
execute_process(
|
||||||
|
COMMAND ${GIT_EXECUTABLE} describe --always --dirty
|
||||||
|
WORKING_DIRECTORY "${SRC}"
|
||||||
|
OUTPUT_VARIABLE GIT_OUT
|
||||||
|
OUTPUT_STRIP_TRAILING_WHITESPACE
|
||||||
|
ERROR_QUIET
|
||||||
|
RESULT_VARIABLE GIT_RC)
|
||||||
|
if(GIT_RC EQUAL 0 AND NOT GIT_OUT STREQUAL "")
|
||||||
|
set(COMMIT "${GIT_OUT}")
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
string(TIMESTAMP STAMP "%Y-%m-%d %H:%M UTC" UTC)
|
||||||
|
|
||||||
|
file(WRITE "${OUT}"
|
||||||
|
"// SPDX-License-Identifier: MIT
|
||||||
|
// Generated by cmake/write_build_info.cmake. Do not edit, do not commit.
|
||||||
|
#include \"build_info.hpp\"
|
||||||
|
|
||||||
|
namespace udpscope::build_info {
|
||||||
|
|
||||||
|
const char* const kVersion = \"${VERSION}\";
|
||||||
|
const char* const kCommit = \"${COMMIT}\";
|
||||||
|
const char* const kTimestamp = \"${STAMP}\";
|
||||||
|
|
||||||
|
} // namespace udpscope::build_info
|
||||||
|
")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=udpscope
|
||||||
|
GenericName=Oscilloscope
|
||||||
|
Comment=An oscilloscope for UDPStreamer sources
|
||||||
|
Exec=@UDPSCOPE_EXEC@
|
||||||
|
Icon=org.udpscope.app
|
||||||
|
Terminal=false
|
||||||
|
Categories=Development;Science;Electronics;
|
||||||
|
Keywords=oscilloscope;UDP;signal;waveform;trigger;
|
||||||
|
StartupNotify=true
|
||||||
Vendored
+1
-1
Submodule deps/plotter updated: 0ee6e10c81...2cb31cbb91.
Vendored
+315
-30
@@ -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,6 +158,16 @@ 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. */
|
||||||
@@ -181,12 +198,20 @@ struct udps_client {
|
|||||||
|
|
||||||
uint8_t *rxbuf;
|
uint8_t *rxbuf;
|
||||||
|
|
||||||
uint32_t last_counter;
|
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 */
|
||||||
|
|
||||||
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 +500,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 +559,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 +577,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 +825,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 +850,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,6 +1160,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++;
|
||||||
@@ -981,6 +1236,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 +1248,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;
|
||||||
|
|
||||||
@@ -1048,6 +1313,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 +1419,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 +1480,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 +1502,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");
|
||||||
|
|||||||
Vendored
+59
@@ -150,6 +150,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 +187,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 +237,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 +271,14 @@ 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;
|
||||||
} 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 +300,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.
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
# udpscope — user guide
|
||||||
|
|
||||||
|
A viewer for a UDPStreamer source: it connects, decodes the CONFIG, plots the signals you enable,
|
||||||
|
and lets you freeze, measure and derive from them. This is the *user* guide; `README.md` documents
|
||||||
|
what was built and why, and `SPECS.md` is the requirement document.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*The main window with a source streaming: the header bar above (the window control — `2 s` — first on
|
||||||
|
the left), the plot, the badge bar under it with one badge per signal, the status line at the foot, and
|
||||||
|
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
|
||||||
|
|
||||||
|
```sh
|
||||||
|
udpscope # asks which source to open
|
||||||
|
udpscope --server 10.0.0.5 --port 8000 # connect straight away
|
||||||
|
udpscope --server 239.1.2.3 --port 8000 --multicast 239.1.2.3 --interface 10.0.0.9
|
||||||
|
```
|
||||||
|
|
||||||
|
With no `--server` the app shows a chooser: the sessions it has stored, each naming its source, plus
|
||||||
|
a form for a new host. A source opened from the list reconnects to the multicast group and interface
|
||||||
|
it was stored with.
|
||||||
|
|
||||||
|
**A unicast source serves one client at a time.** A second viewer's `CONNECT` displaces the first, and
|
||||||
|
they take turns — the status line says so when it happens. Watching one source from several instances
|
||||||
|
needs `--multicast <group>` with `--interface <local address>`.
|
||||||
|
|
||||||
|
Every launch is its own process, so running two scopes side by side is fine.
|
||||||
|
|
||||||
|
## 2. The status line
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
- **The state word is one word** from a fixed set: `live`, `paused`, `armed`, `capturing`,
|
||||||
|
`event held`, `disarmed`. `no data` appears in the rate's place when nothing is arriving.
|
||||||
|
- **The rate is the *stored* rate**, which is the wire rate only while the whole requested window
|
||||||
|
fits in the buffer. A window wider than the budget allows is bought by averaging groups of samples,
|
||||||
|
so the rate falls and the status line says so — that is the anti-alias filter doing its job.
|
||||||
|
- **`connected, but no data is arriving`** appears after three seconds in which no frame arrived while
|
||||||
|
connected: the usual cause is the unicast point above. It clears the moment data returns.
|
||||||
|
- **`N reconnects`** is appended when the client has had to re-establish the session.
|
||||||
|
|
||||||
|
## 3. Signals
|
||||||
|
|
||||||
|
The **`+` badge** (or `g`) opens the **Signals** page, which lists what can be added: the signals the
|
||||||
|
CONFIG described that are not acquired yet, each with a tick. **Signals start disabled — ticking one
|
||||||
|
is what starts acquiring it**, and a signal nobody enables costs nothing. Its badge then appears in
|
||||||
|
the badge bar. Drag the badge onto a plot row to add a trace; drag it again to another row to plot
|
||||||
|
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.
|
||||||
|
|
||||||
|
Clicking a signal's badge opens its own page. Directly under the name are its **Unit**, **Gain** and
|
||||||
|
**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
|
||||||
|
|
||||||
|
The header's window control (`2 s`) sets the time span on screen; the popover takes a number and a
|
||||||
|
unit, with a decade stepper either side. The trigger's event window is a share of *this* span, so a
|
||||||
|
window change moves the trigger with it.
|
||||||
|
|
||||||
|
- **Wheel** zooms about the pointer; **Shift+wheel** on x only, **Ctrl+wheel** y only.
|
||||||
|
- **Ctrl `+` / `-`** zooms x, **Alt `+` / `-`** y, about the plot centre.
|
||||||
|
- **Drag** pans, **right-drag** too (the right button also grabs a cursor at the pointer).
|
||||||
|
- **Ctrl+drag** frames x to a box, **Alt+drag** y.
|
||||||
|
- **Fit to data** (menu) auto-scales both axes and frames the whole buffered range.
|
||||||
|
|
||||||
|
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,
|
||||||
|
and panning stops where the data starts. When the buffer holds *less* than the window — right after a
|
||||||
|
signal is enabled — the window is left empty on its left, which is what a scope that has just started
|
||||||
|
should look like.
|
||||||
|
|
||||||
|
## 5. Trigger
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*Single mode with a capture held: the axis is the event's own `[-pre, +post]`, the trigger marker
|
||||||
|
sits at the edge, and the status line reads `event held`.*
|
||||||
|
|
||||||
|
The trigger button in the header bar (`t`) turns trigger mode on. The **Trigger** badge opens the
|
||||||
|
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
|
||||||
|
fills in view; the axis does not move.
|
||||||
|
- **A held event is navigable within its own data** — zooming in works, panning stops at the capture's
|
||||||
|
edges — and it is never quietly replaced by live data.
|
||||||
|
- **Disarming mid-capture** publishes what the capture got and holds that, so the picture on screen
|
||||||
|
when you disarm is the picture you keep.
|
||||||
|
- The event window is counted in **stream seconds** end to end, so a source whose clock advances in
|
||||||
|
jumps still gets the window it asked for.
|
||||||
|
- **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
|
||||||
|
|
||||||
|
Pause freezes the *picture*, not the acquisition: the producer keeps filling the buffer behind it, and
|
||||||
|
the frozen view is a snapshot of the window that was on screen. Resuming shows the data that arrived
|
||||||
|
meanwhile — there is no hole. A paused view is held like a held event, so zooming in works and panning
|
||||||
|
does not walk it off its snapshot.
|
||||||
|
|
||||||
|
## 7. Cursors
|
||||||
|
|
||||||
|
The cursors toggle in the header bar (`c`) switches on two instruments per row:
|
||||||
|
|
||||||
|
- **vertical** — two times, shared by every row (and every plot of a split row);
|
||||||
|
- **horizontal** — two rulers per row (every plot of a split row shows the same two values);
|
||||||
|
- **both** — the two together;
|
||||||
|
- **mix** — the two times, read off each *trace* where they cross it.
|
||||||
|
|
||||||
|
The dock reports each trace's values at the markers; **Edit** beside **Cursors** opens the page with
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 8. Measurements
|
||||||
|
|
||||||
|
A measurement is a statistic of the **visible window** of a signal: average, top, bottom, min, max,
|
||||||
|
peak to peak, amplitude, pulse duration, period and frequency. `m`, or **Edit** beside
|
||||||
|
**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
|
||||||
|
between signals where a threshold in volts would not. `period` and `frequency` deliberately have no
|
||||||
|
split: they count rising crossings of the mid level, one edge per cycle, and a split there is a
|
||||||
|
setting you can only get wrong.
|
||||||
|
|
||||||
|
The values are refreshed on a cadence — at most every 100 ms while the window is scrolling, once when
|
||||||
|
it stops, and once per mode change — so a live view cannot cost you frames. The cost is
|
||||||
|
reported in the exit dump with the app's self-check.
|
||||||
|
|
||||||
|
## 9. Math channels
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
*The compact node editor: each node is a box, its inputs the squares on the left edge and its output
|
||||||
|
the square on the right. The orange dot marks the node that carries the channel's value.*
|
||||||
|
|
||||||
|
A math channel is derived from the others and is then an ordinary channel: enable it and it is
|
||||||
|
acquired, plotted, measured and exported like a streamed signal, and another math channel can use it
|
||||||
|
as a source. 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`,
|
||||||
|
`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.
|
||||||
|
- **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.
|
||||||
|
- **Drag** a box to move it. **add node**, **delete node** and **set as output** are below the form.
|
||||||
|
- The **output is the last node** of a graph you build top to bottom, and the orange dot says which;
|
||||||
|
*set as output* changes it. A graph whose output is the source is valid and just passes the input
|
||||||
|
through — that is the usual reason an operation "does nothing".
|
||||||
|
- **Save** refuses an incomplete graph and says why: an unconnected input, no output, or a source that
|
||||||
|
is not a channel the scope is acquiring.
|
||||||
|
|
||||||
|
The samples are produced at ingest, once per stored slot, so the filters carry real history — a low
|
||||||
|
pass is the filter you asked for rather than a window recomputed every frame.
|
||||||
|
|
||||||
|
## 10. Sessions
|
||||||
|
|
||||||
|
Everything above is saved per source in `~/.config/udpscope/<host>_<port>.toml`: the window, the rows
|
||||||
|
and which signal each slot holds, the calibration, **which signals were enabled**, **the math channels
|
||||||
|
with their graphs** and **the measurements**. Reopening the same source brings the scope back as you
|
||||||
|
left it.
|
||||||
|
|
||||||
|
The file is plain TOML and safe to edit — a malformed line is skipped rather than refusing the whole
|
||||||
|
file. Deleting it starts from the defaults.
|
||||||
|
|
||||||
|
## 11. Export
|
||||||
|
|
||||||
|
**Export visible range…** writes the plotted signals over the visible x range at the *stored* rate to
|
||||||
|
`.npy` or `.csv`, picked by the file name's extension. The table is collected when you click, not when
|
||||||
|
the chooser closes, so a live view cannot scroll under the dialog. The `.npy` is a 2-D array: one row
|
||||||
|
per sample, `time`, `stream_time`, then one column per signal.
|
||||||
|
|
||||||
|
## 12. Keyboard
|
||||||
|
|
||||||
|
| key | action |
|
||||||
|
|---|---|
|
||||||
|
| `space` | pause / resume |
|
||||||
|
| `a` | arm / disarm the trigger |
|
||||||
|
| `t` | enable / disable trigger mode |
|
||||||
|
| `c` | enable / disable the cursors |
|
||||||
|
| `m` | open or close the Measurements page |
|
||||||
|
| `g` | open or close the Signals page (the `+` badge opens it too) |
|
||||||
|
| `l` | show / hide the layout editor |
|
||||||
|
| `s` | show / hide the dock |
|
||||||
|
| `Esc` | close the slide-over page (the guided tour takes it first) |
|
||||||
|
| `f` | fit to data (the plot's own key) |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
The plot also answers `+`/`-` (zoom both axes), `Ctrl+plus`/`Ctrl+minus` (zoom x only),
|
||||||
|
`Alt+plus`/`Alt+minus` (zoom y only) and the arrow keys (pan). A keystroke is ignored while a text
|
||||||
|
field has focus, so typing in the window control never arms the trigger.
|
||||||
|
|
||||||
|
## 13. Troubleshooting
|
||||||
|
|
||||||
|
| symptom | cause |
|
||||||
|
|---|---|
|
||||||
|
| `connected, but no data is arriving` | another client holds a unicast source — use `--multicast` |
|
||||||
|
| the plot is empty, the status reads `no data` | nothing is enabled: enable a signal in the panel |
|
||||||
|
| the trace is blank while the trigger is armed | `Waiting` — armed and nothing latched yet |
|
||||||
|
| the axis has a margin at the right | a capture is filling: the post side arrives as it is acquired |
|
||||||
|
| a math channel equals its input | its output node is the source — see §9 |
|
||||||
|
| a very wide window shows a low rate | the ingest's anti-alias filter, not a fault |
|
||||||
|
| a level reads `--` | the measurement is invalid in this window (e.g. a period with one edge) |
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
# 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` unchanged; `udps_*` names appear only in
|
||||||
|
`src/source/udps_*.cpp` (`grep -rn "udps_" src` outside those files is a leak of the old coupling).
|
||||||
|
- 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,368 @@
|
|||||||
|
# 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.)
|
||||||
|
|
||||||
|
`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 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. Five 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=`.
|
||||||
|
|
||||||
|
`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,724 @@
|
|||||||
|
# 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.
|
||||||
|
- **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.
|
||||||
@@ -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,339 @@
|
|||||||
|
# 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 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,342 @@
|
|||||||
|
# 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 (the hovered cell shows its
|
||||||
|
y), and the **vertical cursor pair is one shared time drawn in every cell** (`cursors_aligned`).
|
||||||
|
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.
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
# 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.
|
||||||
@@ -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,194 @@
|
|||||||
|
# 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.
|
||||||
@@ -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,631 @@
|
|||||||
|
# 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.
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
Binary file not shown.
|
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.
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,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.
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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,236 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# Source chooser at startup
|
||||||
|
|
||||||
|
Launched with no `--server`, udpscope connects to `127.0.0.1:8000` because that is the
|
||||||
|
default in `Config`. There is no way to pick a source from the application, and no way to
|
||||||
|
see which sources it already knows about — a user who runs it against several streamers has
|
||||||
|
to remember each host and port and retype them. This adds a pre-flight dialog that lists
|
||||||
|
the sources with a stored session and offers a form for a new one.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In: the chooser, the connection form, the stored connection settings a chooser row needs to
|
||||||
|
reconnect, and the probes that check them.
|
||||||
|
|
||||||
|
Out: switching source without restarting; editing a stored source in place (forget it and
|
||||||
|
re-add); network discovery; a "test connection" button. Each is a separate feature and none
|
||||||
|
is needed to pick a source at startup.
|
||||||
|
|
||||||
|
## When the chooser appears
|
||||||
|
|
||||||
|
`Config` gains `bool server_specified = false`, set only by the `--server` branch of
|
||||||
|
`parse_args()`. `main()` runs the chooser if and only if it is false. `Config::server` keeps
|
||||||
|
its `127.0.0.1` default, so nothing downstream has to cope with an unset host.
|
||||||
|
|
||||||
|
The condition is `--server` alone, not "no arguments": `--multicast` still needs a server to
|
||||||
|
fetch CONFIG from, so a run that gives a group but no server is a run that must still be
|
||||||
|
asked which host to ask.
|
||||||
|
|
||||||
|
Every headless probe documented in CLAUDE.md passes `--server`, so none of them reaches the
|
||||||
|
chooser and none changes behaviour. That is the property that makes this safe to add to a
|
||||||
|
project whose entire regression net is those probes.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
main()
|
||||||
|
parse_args --server absent?
|
||||||
|
| yes
|
||||||
|
choose_source(cfg) gtk_init(), dialog, nested GMainLoop
|
||||||
|
| false (cancelled) -> return 0
|
||||||
|
| true (chosen)
|
||||||
|
ScopeModel(cfg) \
|
||||||
|
Acquisition(cfg) | unchanged
|
||||||
|
run_ui(cfg, ...) /
|
||||||
|
```
|
||||||
|
|
||||||
|
The chooser runs before `ScopeModel` exists, so a cancel costs no buffer allocation and no
|
||||||
|
socket, and `run_ui()`, `ScopeModel`, `Acquisition` and `RowSet` are untouched. `gtk_init()`
|
||||||
|
followed by a nested `GMainLoop`, and then later `gtk_application_new()` +
|
||||||
|
`g_application_run()` inside `run_ui()`, works: verified in a throwaway program before this
|
||||||
|
design was settled (`gtk_init()` is idempotent, and running your own loop is the documented
|
||||||
|
GTK4 replacement for the removed `gtk_dialog_run()`).
|
||||||
|
|
||||||
|
Cancel exits **0**, not an error: declining to choose a source is not a failure.
|
||||||
|
|
||||||
|
## Known sources
|
||||||
|
|
||||||
|
`layout_store` gains:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
struct KnownSource {
|
||||||
|
std::string host;
|
||||||
|
std::uint16_t port = 0;
|
||||||
|
std::string multicast; ///< empty = unicast
|
||||||
|
std::string interface_addr;
|
||||||
|
int rows = 0; ///< plots the session had
|
||||||
|
int signals = 0; ///< named slots across those plots
|
||||||
|
std::int64_t mtime = 0; ///< for the ordering; not shown
|
||||||
|
};
|
||||||
|
|
||||||
|
std::vector<KnownSource> known_sources();
|
||||||
|
```
|
||||||
|
|
||||||
|
It scans `config_dir()` for `*.toml`, splits each stem at the **last** `_` into host and
|
||||||
|
port (the host part may itself contain `_`, since `layout_path()` replaces unsafe characters
|
||||||
|
with it), parses the file with the existing `load_layout()`, and keeps the entry only when
|
||||||
|
at least one row slot names a signal. Sorted by `mtime`, newest first.
|
||||||
|
|
||||||
|
This lives in `layout_store` because that file already owns `config_dir()`, the
|
||||||
|
`<host>_<port>.toml` scheme and the parser. A chooser that re-derived any of the three would
|
||||||
|
be a second expression of the same decision, which is how two parts of this codebase have
|
||||||
|
disagreed before.
|
||||||
|
|
||||||
|
**The filter is what makes the list usable.** A development machine accumulates one session
|
||||||
|
file per probe run on a throwaway port — 140 of them at the time of writing, all with
|
||||||
|
`signals = ["", "", "", ""]`. Requiring a named signal means the list shows sources somebody
|
||||||
|
actually configured.
|
||||||
|
|
||||||
|
`forget_source(const KnownSource&)` deletes the file, for the chooser's `×`.
|
||||||
|
|
||||||
|
## Stored connection settings
|
||||||
|
|
||||||
|
A session file is named `<host>_<port>.toml`, so its name cannot say whether that source was
|
||||||
|
multicast or which interface it joined on. `LayoutState` therefore gains:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::string multicast; ///< empty = unicast
|
||||||
|
std::string interface_addr; ///< empty = default route
|
||||||
|
```
|
||||||
|
|
||||||
|
`save_layout()` emits them, when non-empty, in the **top block** (layout_store.cpp:325-338,
|
||||||
|
before the first `[[plot]]`). This is not a stylistic choice: in TOML a bare key written
|
||||||
|
after a table header belongs to that table, which is exactly how the `enabled` key was
|
||||||
|
parsed into the last `[[calibration]]` record and silently lost. `load_layout()` reads them
|
||||||
|
under `Section::Top`. A file written by an older build has neither key and loads as unicast.
|
||||||
|
|
||||||
|
The values come from `Config` and never change at runtime, so `App` carries them beside
|
||||||
|
`source_host`/`source_port` and `collect_layout()` writes them back verbatim.
|
||||||
|
|
||||||
|
The host in the filename stays the CONFIG server even for a multicast session, as today; the
|
||||||
|
group is an attribute of that source rather than its identity.
|
||||||
|
|
||||||
|
## The chooser dialog
|
||||||
|
|
||||||
|
New `src/source_dialog.{hpp,cpp}`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// Asks which source to open. Returns false if the user declined, in which case `cfg` is
|
||||||
|
/// untouched and the caller should exit. Fills `cfg.server`/`port`/`multicast`/
|
||||||
|
/// `interface_addr` on true.
|
||||||
|
bool choose_source(Config& cfg);
|
||||||
|
```
|
||||||
|
|
||||||
|
A separate file rather than more of `src/ui.cpp`: that file is 7400 lines and already the
|
||||||
|
largest in the project, and this shares no state with it — it runs before the `App` struct
|
||||||
|
exists.
|
||||||
|
|
||||||
|
A `GtkWindow` titled `udpscope — choose a source`, holding a `GtkListBox` inside a
|
||||||
|
`GtkScrolledWindow` (the list is unbounded) and an action row:
|
||||||
|
|
||||||
|
```
|
||||||
|
udpscope — choose a source
|
||||||
|
─────────────────────────────────
|
||||||
|
127.0.0.1:8000 3 plots, 4 signals ×
|
||||||
|
10.0.0.5:8000 1 plot, 2 signals ×
|
||||||
|
10.0.0.5:8001 2 plots, 3 signals, multicast 239.1.2.3 ×
|
||||||
|
─────────────────────────────────────────────────────────────
|
||||||
|
[ New host… ] [ Cancel ]
|
||||||
|
```
|
||||||
|
|
||||||
|
A multicast row is titled by its **server**, not by its group: that is the host the session
|
||||||
|
file is named after and the host CONFIG is fetched from. The group is a line of subtitle.
|
||||||
|
|
||||||
|
- Each row: `host:port` in the normal face over a dimmed `N plots, M signals`, with
|
||||||
|
`multicast GROUP` appended when the session stored one.
|
||||||
|
- Activating a row — click or Enter, i.e. `GtkListBox::row-activated` — fills `cfg` from
|
||||||
|
that `KnownSource` and ends the loop with true.
|
||||||
|
- `×` calls `forget_source()` and removes the row. **No confirmation**: the gesture is the
|
||||||
|
confirmation, and what it destroys is one session's layout, which the app rewrites on the
|
||||||
|
next clean exit. It is a `GtkButton` inside the row, so it must not also activate the row;
|
||||||
|
it is packed at the end and stops there by having its own handler.
|
||||||
|
- `[New host…]` opens the connection form below.
|
||||||
|
- `[Cancel]`, `Escape` and the window's close button all end the loop with false.
|
||||||
|
- With no known sources the list is replaced by a dimmed line (`No saved sources yet.`) and
|
||||||
|
`[New host…]` takes the initial focus, so the dialog is never an empty box.
|
||||||
|
|
||||||
|
## The connection form
|
||||||
|
|
||||||
|
A second `GtkWindow`, transient for and modal on the chooser:
|
||||||
|
|
||||||
|
| Field | Widget | Default | Empty means |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Server | `GtkEntry` | `127.0.0.1` | — (required) |
|
||||||
|
| Port | `GtkSpinButton`, 1..65535 | `8000` | — |
|
||||||
|
| Multicast group | `GtkEntry` | empty | unicast |
|
||||||
|
| Interface | `GtkEntry` | empty | default route |
|
||||||
|
|
||||||
|
`[Connect]` writes the four fields into `cfg` and ends both loops with true; `[Cancel]`
|
||||||
|
returns to the chooser. `[Connect]` is insensitive while `Server` is empty — the only
|
||||||
|
validation here. A *malformed* address is left to the acquisition layer, which already
|
||||||
|
reports it, and the starvation notice covers a source that accepts the socket and then says
|
||||||
|
nothing; duplicating that check in the dialog would be a second expression of it.
|
||||||
|
|
||||||
|
These four fields and no others. `--buffer-mb`, `--max-window`, `--holdoff`,
|
||||||
|
`--recv-buffer` and `--plots` are tuning rather than "which source", and a form that asks
|
||||||
|
for them is a form a user has to read before using the app.
|
||||||
|
|
||||||
|
Connecting to a host that already has a session file is not a special case: the file is
|
||||||
|
keyed on host and port, so `run_ui()` loads it exactly as it would have.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Case | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| Cancel / close / Escape | exit 0, before any allocation or socket |
|
||||||
|
| `config_dir()` missing or unreadable | empty list, `[New host…]` only |
|
||||||
|
| A session file that fails to parse | `load_layout()` already returns what it parsed; an entry with no named signal is filtered out like any other |
|
||||||
|
| A chosen source that never answers | unchanged from today: CONNECT times out, the client reconnects, the starvation notice fires |
|
||||||
|
| `forget_source()` fails to unlink | the row stays; nothing else to do and nothing was lost |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Probes, reported on stderr in the style of the existing ones, so this is checked by the same
|
||||||
|
net as everything else rather than a separate harness.
|
||||||
|
|
||||||
|
- `UDPSCOPE_PROBE_SOURCES=1` — print the discovered list and exit without showing the
|
||||||
|
dialog:
|
||||||
|
|
||||||
|
```
|
||||||
|
[sources] 3 known: 127.0.0.1:8000 plots=3 signals=4 | 10.0.0.5:8000 plots=1 signals=2 |
|
||||||
|
239.1.2.3:8000 plots=2 signals=3 multicast=239.1.2.3
|
||||||
|
```
|
||||||
|
|
||||||
|
Assertions: only sources with a named signal appear, order is newest first, and a config
|
||||||
|
directory full of probe leftovers reports them **not at all**.
|
||||||
|
|
||||||
|
- `UDPSCOPE_PROBE_PICK_MS=N` — show the chooser, then after N ms activate row 0 and let the
|
||||||
|
run continue into the real application. A full `xvfb-run` run with **no `--server`** must
|
||||||
|
therefore reach the scope and emit every existing dump line unchanged — that is the
|
||||||
|
end-to-end assertion, and it costs no new dump format.
|
||||||
|
|
||||||
|
- `UDPSCOPE_PROBE_PICK_MS` with `UDPSCOPE_PROBE_NEWHOST=host:port` — drive `[New host…]`
|
||||||
|
instead: open the form, fill it, press `[Connect]`. The crash surface of a dialog built
|
||||||
|
and destroyed is as much the point as the state, the same way `UDPSCOPE_PROBE_FIELDS_MS`
|
||||||
|
is.
|
||||||
|
|
||||||
|
- The multicast round trip, run serially: once with `--server`/`--multicast` to write the
|
||||||
|
file, then `grep multicast` on it, then once with no `--server` at all, checking the row
|
||||||
|
reads `multicast` and that `cfg` came back with the group.
|
||||||
|
|
||||||
|
CLAUDE.md gains these under Verification plus an invariant for the TOML top-block rule and
|
||||||
|
the `--server`-absent condition; README.md gains the feature and why the list is filtered.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/config.hpp/.cpp` | `server_specified` flag |
|
||||||
|
| `src/layout_store.hpp/.cpp` | `KnownSource`, `known_sources()`, `forget_source()`, `multicast`/`interface` keys |
|
||||||
|
| `src/source_dialog.hpp/.cpp` | new: the chooser and the form |
|
||||||
|
| `src/main.cpp` | call the chooser when `--server` was absent |
|
||||||
|
| `src/ui.cpp` | carry multicast/interface on `App`, write them in `collect_layout()` |
|
||||||
|
| `CMakeLists.txt` | the new source |
|
||||||
|
| `CLAUDE.md`, `README.md` | the documentation this project requires for both |
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# App icon, Adwaita About dialog, and desktop entry
|
||||||
|
|
||||||
|
Date: 2026-10-04
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Three related pieces of desktop integration udpscope currently lacks:
|
||||||
|
|
||||||
|
1. An application icon drawn to the GNOME HIG, in both full colour and symbolic form.
|
||||||
|
2. An About dialog that shows it, built on `AdwAboutDialog` rather than `GtkAboutDialog`.
|
||||||
|
3. A `.desktop` entry, so the app can be launched from the shell rather than only from a terminal.
|
||||||
|
|
||||||
|
## Decisions taken before design
|
||||||
|
|
||||||
|
**The application ID stays `org.udpscope.app`.** GNOME convention is that the application ID, the
|
||||||
|
icon name and the desktop file's basename are one string, so that string is `org.udpscope.app`
|
||||||
|
throughout: the icon is named `org.udpscope.app`, the desktop file is
|
||||||
|
`org.udpscope.app.desktop`, and `gtk_application_new()` is left exactly as it is. The alternative
|
||||||
|
(moving to `it.ferrarinas.Udpscope`, the reverse-DNS of the project's actual forge domain) was
|
||||||
|
offered and declined. Nothing else in the tree depends on the ID — the config directory is
|
||||||
|
`~/.config/udpscope` and the app is `G_APPLICATION_NON_UNIQUE` — so this choice costs nothing
|
||||||
|
beyond the naming itself.
|
||||||
|
|
||||||
|
**The icon is the instrument face**, not a bare waveform and not a colour version of the existing
|
||||||
|
trigger icon. A dark panel carrying one bold trace is the clearest available metaphor, it is the
|
||||||
|
kind of physical object the HIG's top-and-front depth language was written for, and its silhouette
|
||||||
|
survives being scaled down better than either alternative.
|
||||||
|
|
||||||
|
**The desktop entry is installed by an opt-in CMake target**, not by `install()` rules and not by
|
||||||
|
documented `cp` commands. The project deliberately has no install step — that is the stated reason
|
||||||
|
its icons are a GResource — and a target that does nothing until asked preserves that while still
|
||||||
|
producing a working entry. `install()` rules were rejected because they would create a second copy
|
||||||
|
of the app that can diverge from the build tree; manual `cp` was rejected because `Exec` would need
|
||||||
|
the binary on `PATH`, which it is not.
|
||||||
|
|
||||||
|
## The HIG constraints this is built to
|
||||||
|
|
||||||
|
Taken from <https://developer.gnome.org/hig/guidelines/app-icons.html>:
|
||||||
|
|
||||||
|
- Nominal canvas **128×128**, which the artwork should *not* fill.
|
||||||
|
- A **2px grid** for all detail.
|
||||||
|
- Depth comes from combining the top and front of an object: a front profile along the bottom,
|
||||||
|
**no taller than 2 detail units (4 nominal px)**.
|
||||||
|
- Flat colour on straight surfaces; **gradients reserved for curved surfaces**.
|
||||||
|
- Shadows **avoided** where possible.
|
||||||
|
- The standard GNOME palette.
|
||||||
|
- A **symbolic version is required**, reusing the same metaphor, following the UI-icon (single
|
||||||
|
colour) rules.
|
||||||
|
- The icon must still read at **64px** and **32px**.
|
||||||
|
|
||||||
|
## Component 1 — the artwork
|
||||||
|
|
||||||
|
Two new source files, both added to the existing `src/icons/udpscope.gresource.xml` under the
|
||||||
|
`scalable/apps/` tree (the icons already there are `scalable/actions/`).
|
||||||
|
|
||||||
|
### `src/icons/scalable/apps/org.udpscope.app.svg`
|
||||||
|
|
||||||
|
Full colour, 128×128, every coordinate on the 2px grid:
|
||||||
|
|
||||||
|
| element | geometry | palette colour |
|
||||||
|
|---|---|---|
|
||||||
|
| chassis | rounded rect (12, 22)–(116, 106), radius 8 | Dark 3 `#3d3846` |
|
||||||
|
| front profile | the bottom band of that same silhouette, y 102–106 | Dark 2 `#5e5c64` |
|
||||||
|
| screen | rounded rect (20, 30)–(108, 94), radius 4 | Dark 4 `#241f31` |
|
||||||
|
| trace | one sine cycle, centred y = 62, amplitude 18, over x 24–104, 6 px thick | Green 1 `#8ff0a4` |
|
||||||
|
|
||||||
|
The chassis is **one** rounded rect and the front profile is the bottom 4px *of that silhouette*,
|
||||||
|
drawn as a path that follows its bottom corners — not a separate full-width rectangle underneath it.
|
||||||
|
A plain rect from (12, 102) to (116, 106) would protrude past the radius-8 corners, which at 128px
|
||||||
|
reads as a misdrawn bump and at 32px as a dirty edge.
|
||||||
|
|
||||||
|
That band is exactly the HIG's 4px maximum and is the only depth treatment: no gradients (every
|
||||||
|
surface here is straight, so flat colour is what the HIG asks for) and no shadows.
|
||||||
|
|
||||||
|
The artwork occupies x 12–116 and y 22–106, leaving 12px side margins and 22px top and bottom, so it
|
||||||
|
does not fill the canvas. Every coordinate above is even.
|
||||||
|
|
||||||
|
Two deliberate omissions. There is **no graticule** — the grid lines a real scope face carries
|
||||||
|
cannot survive 32px, and the trace already carries the metaphor without them. And the trace is
|
||||||
|
**one** cycle, a single trough and a single crest, rather than several: at 32px the screen is about
|
||||||
|
22×16 px, and anything busier reads as a smudge.
|
||||||
|
|
||||||
|
### `src/icons/scalable/apps/org.udpscope.app-symbolic.svg`
|
||||||
|
|
||||||
|
16×16, the same metaphor reduced to a screen frame with a wave inside it. Unlike the colour icon
|
||||||
|
this is **filled paths only, with an `evenodd` hole for the screen's interior**, because GTK's
|
||||||
|
symbolic recolour only reaches `fill` on `path`, `rect` and `circle` — a stroke keeps the colour
|
||||||
|
written in the file and stays dark on a dark theme. Whole-pixel coordinates throughout.
|
||||||
|
|
||||||
|
**This asymmetry is the trap worth documenting:** CLAUDE.md's existing icon-authoring rules say
|
||||||
|
"everything is a filled `path`" without qualifying it, because every icon in the tree so far has
|
||||||
|
been symbolic. A 128px colour icon is never recoloured, so it may stroke freely — and stroking is
|
||||||
|
the sane way to draw a smooth sine. The rule becomes "fills only in a symbolic icon".
|
||||||
|
|
||||||
|
## Component 2 — the About dialog
|
||||||
|
|
||||||
|
`adw_about_dialog_new()` returns an `AdwDialog*`. An `AdwDialog` is **not** a `GtkWindow`, which is
|
||||||
|
the trap CLAUDE.md already documents for the shortcuts overview, so in `src/help_window.cpp`:
|
||||||
|
|
||||||
|
- `g_about` changes from `GtkWidget*` to `AdwDialog*`.
|
||||||
|
- It is cleared on `::closed`, not `::close-request` (`AdwDialog` has no such signal).
|
||||||
|
- It closes with `adw_dialog_close()`, not `gtk_window_close()`.
|
||||||
|
- It is presented with `adw_dialog_present()`.
|
||||||
|
- Both `AdwDialog`s can share one `::closed` handler keyed by a pointer-to-pointer, leaving
|
||||||
|
`on_window_close` to the help window alone.
|
||||||
|
|
||||||
|
`close_help_windows()` therefore becomes one `GtkWindow` (help) plus two `AdwDialog`s (About,
|
||||||
|
shortcuts), and both its doc comment and the CLAUDE.md invariant that says "two of the three are
|
||||||
|
`GtkWindow`s" have to be revised to "one of the three".
|
||||||
|
|
||||||
|
Field mapping:
|
||||||
|
|
||||||
|
| `GtkAboutDialog` | `AdwAboutDialog` |
|
||||||
|
|---|---|
|
||||||
|
| `program_name` | `application_name` |
|
||||||
|
| `version` | `version` |
|
||||||
|
| `comments` | `comments` |
|
||||||
|
| `license_type` | `license_type` |
|
||||||
|
| `system_information` | `debug_info` |
|
||||||
|
| — | `application_icon` = `"org.udpscope.app"` |
|
||||||
|
| — | `developer_name`, `website` |
|
||||||
|
|
||||||
|
Two gains are taken while mapping. `debug_info` renders as a Troubleshooting page with a copy
|
||||||
|
button, where `system_information` was inert text. And the string behind it is built as a
|
||||||
|
`std::string` rather than the `char details[1024]` a previous review flagged for silent truncation
|
||||||
|
on a long settings path — removing the ceiling rather than widening it, in code already being
|
||||||
|
rewritten.
|
||||||
|
|
||||||
|
Credits, release notes and an issue URL are deliberately **not** added; nothing asked for them.
|
||||||
|
|
||||||
|
One ordering detail: `gtk_icon_theme_add_resource_path()` currently runs at the top of
|
||||||
|
`build_toolbar()`. The main window's own icon is now set from a name in the same resource, so the
|
||||||
|
registration moves up into `run_ui()`, before both `gtk_window_set_icon_name()` and the
|
||||||
|
`build_toolbar()` call — otherwise whichever of the two runs first resolves its name to nothing.
|
||||||
|
|
||||||
|
## Component 3 — the desktop entry
|
||||||
|
|
||||||
|
### `data/org.udpscope.app.desktop.in`
|
||||||
|
|
||||||
|
```
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=udpscope
|
||||||
|
GenericName=Oscilloscope
|
||||||
|
Comment=An oscilloscope for UDPStreamer sources
|
||||||
|
Exec=@UDPSCOPE_EXEC@
|
||||||
|
Icon=org.udpscope.app
|
||||||
|
Terminal=false
|
||||||
|
Categories=Development;Science;Electronics;
|
||||||
|
Keywords=oscilloscope;UDP;signal;waveform;trigger;
|
||||||
|
StartupNotify=true
|
||||||
|
```
|
||||||
|
|
||||||
|
`@UDPSCOPE_EXEC@` is configured to the built binary's absolute path in the build tree. The
|
||||||
|
consequence to accept knowingly: **moving or deleting the build directory breaks the entry.** That
|
||||||
|
is the price of having no install step, and it is the right price here because the entry then always
|
||||||
|
launches exactly the binary that was last built.
|
||||||
|
|
||||||
|
No `StartupWMClass` is needed. GTK takes the WM class from the application ID, and the desktop
|
||||||
|
file's basename already matches it, which is what associates a running window with this entry.
|
||||||
|
|
||||||
|
### The `desktop-entry` target
|
||||||
|
|
||||||
|
A custom target driving `cmake/install_desktop_entry.cmake`, which writes three files under
|
||||||
|
`$XDG_DATA_HOME` (defaulting to `~/.local/share`):
|
||||||
|
|
||||||
|
- `applications/org.udpscope.app.desktop`
|
||||||
|
- `icons/hicolor/scalable/apps/org.udpscope.app.svg`
|
||||||
|
- `icons/hicolor/symbolic/apps/org.udpscope.app-symbolic.svg`
|
||||||
|
|
||||||
|
The two SVGs are copied from `src/icons/scalable/apps/` in the source tree, not extracted from the
|
||||||
|
GResource: the same two files serve both the in-binary resource and the installed icon theme, so
|
||||||
|
there is one copy to edit.
|
||||||
|
|
||||||
|
It then refreshes `update-desktop-database` and `gtk4-update-icon-cache` if either is on `PATH`, runs
|
||||||
|
`desktop-file-validate` if it is available, and prints every path it wrote.
|
||||||
|
|
||||||
|
`cmake --build build` is unaffected: the target runs only when named. No uninstall target is
|
||||||
|
provided — the three paths are printed, so undoing it is three `rm`s.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Two silent failures are possible, and they fail differently.
|
||||||
|
|
||||||
|
A GResource path that was never registered draws `image-missing` while every other line of the dump
|
||||||
|
passes; the existing `toolbar icons:` line already guards that class with
|
||||||
|
`gtk_icon_theme_has_icon()`. But a **malformed SVG is worse**: librsvg refuses the whole file,
|
||||||
|
`has_icon()` still returns true because the *name* resolves, and the icon draws nothing. `has_icon`
|
||||||
|
cannot see it — and these SVGs have already been bitten once by librsvg, which rejects a file for a
|
||||||
|
`--` inside an XML comment.
|
||||||
|
|
||||||
|
The two are therefore caught in two different places, because **no runtime GTK call can see the
|
||||||
|
malformed one.** This was measured rather than assumed, and three candidate checks were tried and
|
||||||
|
rejected:
|
||||||
|
|
||||||
|
- `gtk_icon_theme_lookup_icon()` never returns `NULL` and its paintable's intrinsic size is the
|
||||||
|
size that was *asked for*, so it reads `128x128` for a valid icon, a malformed one and a name
|
||||||
|
that does not exist at all. The same three cases snapshot to an identical `GSK_TEXTURE_NODE` of
|
||||||
|
the requested bounds, since GTK substitutes `image-missing`. There is nothing to compare.
|
||||||
|
- `GMarkup` accepts `<!-- a -- b -->` without complaint. It is a deliberately lenient subset
|
||||||
|
parser, so it cannot stand in for the strict one that refuses the file.
|
||||||
|
- `gdk_pixbuf_new_from_file()` on an SVG goes through glycin on this system, which sandboxes a
|
||||||
|
subprocess per load, emits fontconfig errors, and refuses this project's existing icons outright
|
||||||
|
for reasons of its own. Not a usable oracle.
|
||||||
|
|
||||||
|
So the malformed file is caught at **build time**, which is better than a probe anyway: every
|
||||||
|
`<file>` line in `src/icons/udpscope.gresource.xml` gains `preprocess="xml-stripblanks"`, which
|
||||||
|
makes `glib-compile-resources` pipe each SVG through `xmllint`. A double hyphen inside a comment
|
||||||
|
then **fails the build** with the file, line and column, instead of drawing nothing at runtime.
|
||||||
|
The four existing icons already pass it. The cost is that the `xmllint` binary becomes a build
|
||||||
|
dependency; it ships with libxml2, which GTK already requires.
|
||||||
|
|
||||||
|
The runtime line then asserts what a runtime check genuinely can:
|
||||||
|
|
||||||
|
```
|
||||||
|
app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ok` is `gtk_icon_theme_has_icon()` **and** the resource bytes being present under the registered
|
||||||
|
path, and the size is the one the file's root `<svg>` element declares, read out of those bytes.
|
||||||
|
**Both entries must read `ok` at those sizes.** That covers the unregistered-path case, a renamed
|
||||||
|
or mis-pathed file, and a canvas size that drifted off the HIG's 128 — and it is computed on the
|
||||||
|
first tick beside `toolbar icons:`, not in the dump, which runs after the window is destroyed.
|
||||||
|
|
||||||
|
Alongside it:
|
||||||
|
|
||||||
|
- The `[help]` probe gains `about icon=org.udpscope.app`, read back through
|
||||||
|
`adw_about_dialog_get_application_icon()`, so the dialog is asserted to carry the icon rather than
|
||||||
|
merely to open.
|
||||||
|
- That probe's existing `closed ok` becomes a stronger crash test, because About is now an
|
||||||
|
`AdwDialog` closed by a different call than the help window beside it. It must still produce no
|
||||||
|
`Gtk-CRITICAL` beyond the two already baselined.
|
||||||
|
- `desktop-file-validate` runs inside the `desktop-entry` target, so a malformed entry fails at the
|
||||||
|
moment it is asked for rather than being silently ignored by the shell later.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **CLAUDE.md**: the `app icons:` dump line as a documented assertion, and the `xml-stripblanks`
|
||||||
|
build-time check beside the five existing icon-authoring rules — the sixth rule is that the
|
||||||
|
malformed-SVG failure is a *build* error now and no runtime check can replace it; the windows invariant revised
|
||||||
|
to one `GtkWindow` and two `AdwDialog`s; the `desktop-entry` target and the build-path caveat; and
|
||||||
|
the icon-authoring rules amended so "filled paths only" is scoped to symbolic icons.
|
||||||
|
- **README.md**: the feature narrative — the icon's HIG constraints and the two omissions
|
||||||
|
(graticule, multiple cycles) with the 32px reasoning, the move to `AdwAboutDialog` and what it
|
||||||
|
gained, and how to get the desktop entry.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- An AppStream `metainfo.xml`. Nothing asked for it, and it is only required for store submission.
|
||||||
|
- Redrawing the four existing action icons.
|
||||||
|
- A nightly/devel icon variant.
|
||||||
|
- An uninstall target for the desktop entry.
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# About, shortcuts, help, and a two-tier status bar
|
||||||
|
|
||||||
|
udpscope has grown a lot of behaviour that is only written down in `CLAUDE.md` and `README.md`:
|
||||||
|
signals start disabled, a long window is decimated rather than refused, a measurement's rulers
|
||||||
|
are stepped because the pass is throttled, an export is the stored samples and not the LOD
|
||||||
|
buckets. None of it is reachable from the application. The plot's own keyboard and mouse
|
||||||
|
gestures are likewise undocumented in the app, and there is no way to tell which binary is
|
||||||
|
running. Meanwhile the status bar carries eleven fields on one line and is unreadable at a
|
||||||
|
glance.
|
||||||
|
|
||||||
|
This adds four things: an About dialog, a shortcut overview, a paged help window, and a
|
||||||
|
two-tier status bar. The first three are windows that explain the application and share a
|
||||||
|
content source; the fourth is independent, and is here because it is the same kind of work on
|
||||||
|
the same surface.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In: the three windows, the accelerator table behind two of them, app-level keyboard shortcuts,
|
||||||
|
one addition to `deps/plotter`'s `input::Controller` (axis-restricted keyboard zoom), and the
|
||||||
|
status bar's split into a glance line and a details popover.
|
||||||
|
|
||||||
|
Out: a translated help (the app is English-only throughout), a search box over the help, a
|
||||||
|
preferences dialog, and any change to what the status line *measures* — the fields move, none
|
||||||
|
is added or deleted.
|
||||||
|
|
||||||
|
## The About dialog
|
||||||
|
|
||||||
|
Three facts, as asked: version, build timestamp, and the path of the persistent settings.
|
||||||
|
|
||||||
|
**Version** is hand-maintained, `project(udpscope VERSION 0.1.0 LANGUAGES C CXX)`. Not
|
||||||
|
`git describe`: the repository has no tags, so `describe` degrades to a bare hash and presents
|
||||||
|
it as a version number.
|
||||||
|
|
||||||
|
**Build timestamp and provenance** come from a generated translation unit. `src/build_info.hpp`
|
||||||
|
declares three constants and a CMake custom command writes `build_info.cpp` defining them on
|
||||||
|
**every** build:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
namespace udpscope::build_info {
|
||||||
|
extern const char* const kVersion; ///< project(VERSION)
|
||||||
|
extern const char* const kCommit; ///< git describe --always --dirty, or "unknown"
|
||||||
|
extern const char* const kTimestamp; ///< "YYYY-MM-DD HH:MM UTC"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`__DATE__`/`__TIME__` are not used: they only move when that translation unit happens to
|
||||||
|
recompile, so a binary built today can honestly report last week. Regenerating unconditionally
|
||||||
|
costs one small compile and one relink per build, and it follows the pattern already in
|
||||||
|
`CMakeLists.txt:43-52`, where `udpscope_resources.c` is a generated source compiled into the
|
||||||
|
executable. The commit is included because it is the fact that actually answers "which binary
|
||||||
|
is this"; a timestamp alone does not. `kCommit` reads `unknown` when `git` is absent or the
|
||||||
|
tree is not a checkout — a build from a tarball must not fail for it.
|
||||||
|
|
||||||
|
**The settings path** is `layout_store::config_dir()`, the same function the session files use,
|
||||||
|
followed by `layout_path(host, port)` for the source this run is connected to — the file a user
|
||||||
|
actually wants to find. Both as selectable labels, so they can be copied.
|
||||||
|
|
||||||
|
The widget is a `GtkAboutDialog`. It is **not** deprecated in GTK 4.22 (it is
|
||||||
|
`GtkShortcutsWindow` that is), it gives the version field, a comments block and an MIT licence
|
||||||
|
section for free, and the two paths go in the comments block, which is the only part with room
|
||||||
|
for them.
|
||||||
|
|
||||||
|
## Keyboard shortcuts
|
||||||
|
|
||||||
|
### The plot's axis-restricted zoom, in `deps/plotter`
|
||||||
|
|
||||||
|
`input::Controller` has `+`/`-` zooming **both** ranges about the plot centre, and no way to
|
||||||
|
zoom one axis from the keyboard. It gains that, with the modifier convention the box-zoom drags
|
||||||
|
already use:
|
||||||
|
|
||||||
|
| keys | effect |
|
||||||
|
| --- | --- |
|
||||||
|
| `+` / `-` | zoom both ranges about the plot centre (unchanged) |
|
||||||
|
| `ctrl` + `+` / `-` | zoom the x axes only |
|
||||||
|
| `alt` + `+` / `-` | zoom the y axes only |
|
||||||
|
|
||||||
|
**Not `shift`, and this is the one deliberate deviation from the approved design.** On the
|
||||||
|
common layouts `+` *is* shift+`=`, and `key_from()` (`adapters/gtk4/gtk_input.hpp:258`) folds
|
||||||
|
both onto `kKeyZoomIn` precisely for that reason — so a bare zoom-in keystroke already arrives
|
||||||
|
with `Mod::Shift` set, and `shift + (+/-)` cannot be distinguished from `+`. Making Shift mean
|
||||||
|
x-only would turn every plain zoom into an x-only zoom. Ctrl and Alt are unambiguous for keys,
|
||||||
|
and they are already the controller's drag convention (`ctrl`+drag = x only, `alt`+drag = y
|
||||||
|
only), so the keyboard and the mouse-drag rows of the overview read the same. The **wheel**
|
||||||
|
keeps its own `shift`=x / `ctrl`=y: that is upstream behaviour with upstream tests, and the
|
||||||
|
overview shows both honestly rather than hiding one.
|
||||||
|
|
||||||
|
The change is smaller than it looks. `KeyEvent` already carries `mods`
|
||||||
|
(`include/plot/input/events.hpp:75`) and `PlotArea::on_key_pressed` already sets
|
||||||
|
`e.mods = mods_from(state)` (`adapters/gtk4/gtk_plot_area.cpp:862`), so no adapter change, no
|
||||||
|
new key constant, and no `key_from()` change: the whole change is the `if (zooms)` block of
|
||||||
|
`Controller::on_key`, which gains the same `has_mod(e.mods, Mod::Ctrl)` / `Mod::Alt` read the
|
||||||
|
drag branch makes at `src/input/controller.cpp:493-495` and skips the axis class the modifier
|
||||||
|
excluded. A comment on `kKeyZoomIn` records why Shift is not available here, so the next person
|
||||||
|
to reach for it finds the reason.
|
||||||
|
|
||||||
|
Two tests in the controller's existing suite — `ctrl_plus_zooms_x_only_and_leaves_y` and
|
||||||
|
`alt_plus_zooms_y_only_and_leaves_x` — then the submodule commit and the pin move.
|
||||||
|
|
||||||
|
### The application's keys
|
||||||
|
|
||||||
|
| keys | action |
|
||||||
|
| --- | --- |
|
||||||
|
| `Space` | pause / resume |
|
||||||
|
| `a` | arm / disarm the trigger |
|
||||||
|
| `t` | trigger panel |
|
||||||
|
| `c` | cursors pane |
|
||||||
|
| `m` | measurements pane |
|
||||||
|
| `g` | math pane |
|
||||||
|
| `l` | layout editor |
|
||||||
|
| `s` | side panel |
|
||||||
|
| `Ctrl+E` | export the visible range |
|
||||||
|
| `F1` | help |
|
||||||
|
| `Ctrl+slash` | shortcut overview |
|
||||||
|
| `Ctrl+Q` | quit |
|
||||||
|
|
||||||
|
Bare letters for the toggles, Ctrl for the things that touch files or windows. The plot's own
|
||||||
|
keys — `+`, `-`, `f`, the arrows, `Escape` — are not in this set and are not shadowed.
|
||||||
|
|
||||||
|
One `GtkEventControllerKey` on the toplevel, in the **bubble** phase. That is what makes a
|
||||||
|
focused widget win its own keys: the plot area returns `FALSE` for everything `key_from()` maps
|
||||||
|
to `0`, which is every letter in this table and `Space` too, so they bubble up to this
|
||||||
|
controller. A focused `GtkEntry` or `GtkSpinButton` consumes its printable keys first, so
|
||||||
|
typing into the window entry or a math field is unaffected. A focused `GtkButton` consumes
|
||||||
|
`Space` to activate itself, so `Space` pauses only when focus is not on a button — ordinary GTK
|
||||||
|
behaviour, and the right outcome. The handler additionally refuses every app key while the
|
||||||
|
focused widget is a `GtkEditable`: belt and braces, and the property a probe can assert, since
|
||||||
|
a probe calls the handler directly and so bypasses the bubbling the first guarantee rests on.
|
||||||
|
|
||||||
|
### The overview window
|
||||||
|
|
||||||
|
Hand-rolled, not `GtkShortcutsWindow`: that widget was deprecated in GTK 4.18, this project
|
||||||
|
builds `-Wall -Wextra -Wpedantic` clean, and 4.22 is what is installed.
|
||||||
|
|
||||||
|
A `GtkWindow` holding a `GtkScrolledWindow` over one two-column `GtkGrid` per section: the
|
||||||
|
accelerator on the left in a monospace face, the description on the right. Sections:
|
||||||
|
**Acquisition**, **Panes and editors**, **Files and windows**, **Plot keyboard**, **Plot
|
||||||
|
mouse**. The mouse section is the controller's gesture table in prose — `shift`/`ctrl`/`alt` +
|
||||||
|
drag for the three box zooms, drag and right-drag to pan, middle-drag on each label strip for
|
||||||
|
an axis zoom, the wheel with its two modifiers, horizontal wheel to pan x, double-click to
|
||||||
|
re-fit.
|
||||||
|
|
||||||
|
## The accelerator table
|
||||||
|
|
||||||
|
`src/shortcuts.hpp` holds **one** table, and both consumers read it:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
enum class AppAction { PauseToggle, TriggerArm, TriggerPanel, CursorsPane, MeasuresPane,
|
||||||
|
MathPane, LayoutEditor, SidePanel, Export, Help, Shortcuts, Quit };
|
||||||
|
|
||||||
|
struct Accel {
|
||||||
|
unsigned keyval; ///< GDK keyval
|
||||||
|
unsigned mods; ///< GdkModifierType mask, 0 for a bare key
|
||||||
|
AppAction action;
|
||||||
|
const char* accel_text; ///< what the overview prints
|
||||||
|
const char* description;
|
||||||
|
const char* section;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`ui.cpp`'s handler looks up `(keyval, mods)` in this table to get an `AppAction` and switches on
|
||||||
|
that; the overview renders `accel_text` and `description` from the same rows. A key that works
|
||||||
|
and a key that is documented therefore cannot drift apart — which is the failure this project
|
||||||
|
has hit before in another form, two comments in `build_toolbar()` disagreeing with each other
|
||||||
|
and one of them with reality.
|
||||||
|
|
||||||
|
The mouse gestures are a second, display-only table in the same file — a gesture text and a
|
||||||
|
description, with no keyval, since nothing dispatches them — because they are rendered by the
|
||||||
|
same window and belong beside the keys they are compared against.
|
||||||
|
|
||||||
|
## The help window
|
||||||
|
|
||||||
|
A `GtkWindow` split in two: a `GtkListBox` of page titles on the left, a `GtkScrolledWindow`
|
||||||
|
holding one wrapping `GtkLabel` on the right. Selecting a row swaps the label's markup. Opened
|
||||||
|
by `F1` and from the overflow menu; **non-modal**, so it can sit beside the scope while a user
|
||||||
|
follows it.
|
||||||
|
|
||||||
|
Each page is a file of **Pango markup** in a GResource (`src/help/*.txt`, bundled by
|
||||||
|
`src/help/udpscope-help.gresource.xml`, compiled by the same `glib-compile-resources` custom
|
||||||
|
command pattern the icons use). Pango markup rather than Markdown because `GtkLabel` renders it
|
||||||
|
natively — bold, italic and monospace spans with no parser to write — and a malformed span is
|
||||||
|
caught by `pango_parse_markup()` at load time rather than drawn wrong. A GResource rather than
|
||||||
|
files beside the binary for the reason already recorded for the icons: udpscope is run straight
|
||||||
|
out of the build tree and has no install step, so a search path would have to be located at
|
||||||
|
runtime and the failure mode of not locating it is silent.
|
||||||
|
|
||||||
|
A page's title, resource path and section header are one entry in a static table in
|
||||||
|
`help_window.cpp`, so the order on screen is explicit in code rather than implied by file names.
|
||||||
|
The section headers are non-selectable rows in the `GtkListBox`.
|
||||||
|
|
||||||
|
### Page shape
|
||||||
|
|
||||||
|
Every page has the same four parts:
|
||||||
|
|
||||||
|
1. what the feature does,
|
||||||
|
2. how to reach it,
|
||||||
|
3. **Worth knowing** — the surprising behaviour, distilled from the invariants in `CLAUDE.md`
|
||||||
|
and `README.md`,
|
||||||
|
4. **Example** — one worked case with real numbers taken from the probes, not round ones.
|
||||||
|
|
||||||
|
The approved template, which every other page follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
Time window
|
||||||
|
───────────
|
||||||
|
|
||||||
|
The window is how much time the plot shows. Set it from the header bar's
|
||||||
|
window button: type a number, pick a unit, or step a decade with the arrows.
|
||||||
|
|
||||||
|
Every row shares one window. The trigger's event span follows it, and the
|
||||||
|
pre-trigger share is a share of *this* window.
|
||||||
|
|
||||||
|
Worth knowing
|
||||||
|
A window too long to store at the wire rate is decimated, not refused. The
|
||||||
|
shared clock takes a power-of-two stride; each group of samples is
|
||||||
|
*averaged*, never strided, so a tone above the stored Nyquist is attenuated
|
||||||
|
instead of aliasing to a false low frequency.
|
||||||
|
|
||||||
|
Moving the window across a decimation boundary rebuilds the buffer and drops
|
||||||
|
the history.
|
||||||
|
|
||||||
|
Example
|
||||||
|
1 MS/s, 200 MB of buffer. A 2 s window stores every sample (time x1). Widen
|
||||||
|
to 600 s and the stride becomes x16: 625 S/s stored, anti-aliased at
|
||||||
|
312.5 Hz.
|
||||||
|
```
|
||||||
|
|
||||||
|
### The pages
|
||||||
|
|
||||||
|
**Getting started**
|
||||||
|
|
||||||
|
1. **Overview** — what each part of the window is: toolbar, side panel, plot rows, status bar.
|
||||||
|
The map the other pages refer back to.
|
||||||
|
2. **Choosing a source** — the startup chooser, why the list shows only sessions that name a
|
||||||
|
signal, what `×` deletes, the New host… form, and that a stored multicast session reconnects
|
||||||
|
to its group and interface.
|
||||||
|
3. **Signals and calibration** — signals start **disabled** and enabling is what starts
|
||||||
|
acquisition; a signal on no plot still gets a grey chip; calibration is applied at ingest, so
|
||||||
|
changing it rebuilds the buffer and drops history.
|
||||||
|
4. **Rows, layout and drag & drop** — drag a signal's chip from the side panel onto a row to
|
||||||
|
plot it; four slots per row; a signal's colour follows the *signal*, not the slot, so the same
|
||||||
|
signal on two rows is one colour; the layout editor's `+`/`×`, and that inserting a row
|
||||||
|
carries the existing rows' contents down with them rather than renumbering under the user.
|
||||||
|
|
||||||
|
**Viewing**
|
||||||
|
|
||||||
|
5. **Time window** — the template above.
|
||||||
|
6. **Navigating the plot** — the keyboard and mouse gestures in prose, plus what a *pinned* view
|
||||||
|
is: while an event is held, a capture is filling or the view is paused, a zoom in works but
|
||||||
|
the view cannot leave the data.
|
||||||
|
7. **Pause and play** — pausing freezes the *display*, not the producer: the ring keeps filling,
|
||||||
|
the view binds a snapshot, and the axis is pinned to the snapshot's own stamps, so a window
|
||||||
|
reaching back past the oldest retained sample shows a shorter span rather than an empty
|
||||||
|
margin.
|
||||||
|
8. **Trigger** — arm/disarm, edge and level, Normal vs Single, the pre-trigger share as a share
|
||||||
|
of *this* window, the four display states (live, capturing, held, waiting), that the trigger
|
||||||
|
sees the **stored** filtered signal, and that disarming mid-capture publishes what the capture
|
||||||
|
got to.
|
||||||
|
|
||||||
|
**Analysis**
|
||||||
|
|
||||||
|
9. **Cursors** — the shared vertical pair versus the per-row rulers, the four modes, what `mix`
|
||||||
|
reads, and that a marker past the newest sample reads `--`.
|
||||||
|
10. **Measurements** — the types, the threshold share, the on-plot marks, that a pass is
|
||||||
|
throttled to a cadence so a ruler is stepped, and that an invalid pass keeps its level marks
|
||||||
|
and drops its time marks.
|
||||||
|
11. **Math channels** — a graph evaluated as an ordered list, so a cycle is impossible by
|
||||||
|
construction; a derived channel *is* a channel; the filters run at the **stored** rate, so a
|
||||||
|
cutoff moves when the window changes the decimation; the output defaults to the last node.
|
||||||
|
|
||||||
|
**Output and state**
|
||||||
|
|
||||||
|
12. **Saving and export** — `.npy` and `.csv`, the visible range at the **stored** rate and never
|
||||||
|
the LOD buckets, the `time`/`stream_time` columns, and that the table is collected at click
|
||||||
|
time rather than when the file chooser returns.
|
||||||
|
13. **Sessions and settings** — where the files live, what a session persists (window, rows,
|
||||||
|
calibration, enabled signals, math graphs, measurements), when it is written, and why a
|
||||||
|
session whose streamer was down during a run is not overwritten.
|
||||||
|
14. **Status bar and diagnostics** — the glance line, what is behind ⓘ, and what `gaps`,
|
||||||
|
`reconnects` and the starvation notice actually mean.
|
||||||
|
|
||||||
|
## The status bar
|
||||||
|
|
||||||
|
### The glance line
|
||||||
|
|
||||||
|
One row on the bar, four fields and no more:
|
||||||
|
|
||||||
|
```
|
||||||
|
● live · 10.0 kS/s · 2 s window ⓘ
|
||||||
|
```
|
||||||
|
|
||||||
|
The badge is unchanged — one glyph in three colours. The state field becomes one short word:
|
||||||
|
`live`, `paused`, `armed`, `capturing`, `event held`, `disarmed`. The long prose it carries
|
||||||
|
today (`trigger: EVENT HELD (Arm to re-arm, or 'Go live' for streaming)`) moves into the
|
||||||
|
popover. Trigger mode adds exactly one field, `· event 4.2 s ago`.
|
||||||
|
|
||||||
|
Each field's width is bounded, for the reason `CLAUDE.md` already gives for `rate` collapsing
|
||||||
|
to one figure and for `format_elapsed()` stepping units: the status row sits directly above the
|
||||||
|
plot, so a field whose width tracks the stream relayouts the plot. That is why the state field
|
||||||
|
is a word from a fixed set rather than a sentence.
|
||||||
|
|
||||||
|
The transient notice (`App::notice`) stays on the bar, prefixed to the glance line as it is
|
||||||
|
today: it is the one piece of text that must be seen without a click.
|
||||||
|
|
||||||
|
### The details popover
|
||||||
|
|
||||||
|
Behind an ⓘ `GtkMenuButton` at the end of the row, a `GtkPopover` over a `GtkGrid` of
|
||||||
|
label/value pairs:
|
||||||
|
|
||||||
|
```
|
||||||
|
Stream connected, configured
|
||||||
|
packets 717 · frames 716 · gaps 0
|
||||||
|
3 signals · 3 channels
|
||||||
|
Buffer 200.0 MB · 1677 s span · time x1
|
||||||
|
stored 10.0 kS/s (wire 10.0 kS/s)
|
||||||
|
Plot 1 3 traces · 195000 samples
|
||||||
|
Reconnects 0
|
||||||
|
```
|
||||||
|
|
||||||
|
It is rewritten **only while it is visible**. A popover nobody opened costs nothing per frame,
|
||||||
|
and a visible one cannot relayout the plot because a popover is its own surface — which is the
|
||||||
|
property that makes the full-width detail safe to show at all.
|
||||||
|
|
||||||
|
### One composition, two consumers
|
||||||
|
|
||||||
|
`src/status_text.hpp` holds a `StatusSnapshot` of the fields the tick already computes, and two
|
||||||
|
formatters:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::string format_glance(const StatusSnapshot&); ///< the bar
|
||||||
|
std::string format_detail(const StatusSnapshot&); ///< the popover, and the exit dump
|
||||||
|
```
|
||||||
|
|
||||||
|
`CLAUDE.md` asserts that `rate` and `last event … ago` are checked in the exit dump's
|
||||||
|
`dbg_mode_live`/`dbg_mode_triggered`, which today are `gtk_label_get_text(app->status_label)`.
|
||||||
|
The tick therefore still assembles the complete detail text every frame, into
|
||||||
|
`App::status_detail`; the dump reads **that** instead of the label, and the popover formats
|
||||||
|
from the same snapshot. The existing dump assertions keep working unchanged, and the detail is
|
||||||
|
not a second expression of fields the glance line also computes.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/build_info.hpp` | new: declares the three constants |
|
||||||
|
| *generated* `build_info.cpp` | new: defines them, rewritten every build |
|
||||||
|
| `src/shortcuts.hpp/.cpp` | new: the accelerator table and the mouse-gesture table |
|
||||||
|
| `src/help_window.hpp/.cpp` | new: `show_help_window()`, `show_shortcuts_window()`, `show_about_dialog()` |
|
||||||
|
| `src/help/*.txt`, `src/help/udpscope-help.gresource.xml` | new: the fourteen pages |
|
||||||
|
| `src/status_text.hpp/.cpp` | new: `StatusSnapshot`, `format_glance()`, `format_detail()` |
|
||||||
|
| `src/ui.cpp` | the key controller, three menu items, the ⓘ button and popover, the glance label, `App::status_detail`, the new probes |
|
||||||
|
| `CMakeLists.txt` | `VERSION`, the build-info command, the help resource, the new sources |
|
||||||
|
| `deps/plotter` | `Controller::on_key`'s zoom branch, two tests, the pin move |
|
||||||
|
| `CLAUDE.md`, `README.md` | the documentation this project requires for both |
|
||||||
|
|
||||||
|
The three windows share one translation unit because they share one purpose, the GResource
|
||||||
|
access and the accelerator table. `src/ui.cpp` is already ~7400 lines and the largest file in
|
||||||
|
the project, so none of them goes in it.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Case | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| A help page's resource is missing | the page's label reads a one-line "this page is missing" and the probe's `pages`/`parsed` counts disagree; nothing else is affected |
|
||||||
|
| A help page's markup is malformed | `pango_parse_markup()` fails, the page falls back to the same line, and `parsed` < `pages` |
|
||||||
|
| `git` absent or not a checkout | `kCommit` is `unknown`; the build succeeds |
|
||||||
|
| An app key pressed while a `GtkEditable` has focus | refused, the widget keeps it |
|
||||||
|
| A window already open | presented, not rebuilt — each of the three keeps one static instance pointer, and closes with `gtk_window_close()`, never `gtk_window_destroy()`, so the handler that clears the pointer runs (the trap the math dialog hit) |
|
||||||
|
| The ⓘ popover opened with no CONFIG yet | the detail fields read what they read today in that state; no special case |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Probes on stderr in the style of the existing ones, since this project's whole regression net is
|
||||||
|
those probes and `CLAUDE.md` requires extending them rather than adding a harness.
|
||||||
|
|
||||||
|
- **`UDPSCOPE_PROBE_HELP_MS`** — open all three windows, report, close them. Equal parts crash
|
||||||
|
test and content check, the way `UDPSCOPE_PROBE_MATH_MS` is:
|
||||||
|
|
||||||
|
```
|
||||||
|
[help] pages=14 parsed=14 shortest='Overview' 412 B; shortcuts: app=12 mouse=9;
|
||||||
|
about version=0.1.0 commit=53815ad built=2026-10-04 14:22 UTC
|
||||||
|
settings=/home/martino/.config/udpscope; closed ok
|
||||||
|
```
|
||||||
|
|
||||||
|
**`parsed` must equal `pages`**, which is what catches a malformed span as a number instead of
|
||||||
|
as mangled text on screen; **no page may be empty**, which catches a resource listed in the
|
||||||
|
`.xml` and never written; `settings=` must be a real path. `closed ok` with no critical is the
|
||||||
|
lifecycle assertion.
|
||||||
|
|
||||||
|
- **`UDPSCOPE_PROBE_KEYS_MS`** — fire every row of the accelerator table at the handler directly
|
||||||
|
(the way the other probes emit `::clicked`) and report the state each one moved:
|
||||||
|
|
||||||
|
```
|
||||||
|
[keys] a: armed 0->1 | space: paused 0->1 | c: cursors 0->1 | ... |
|
||||||
|
editable focused: 'm' refused=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Every row must move exactly its own state and nothing else, and the last field must read
|
||||||
|
`refused=1`.
|
||||||
|
|
||||||
|
- **`UDPSCOPE_PROBE_ZOOMKEYS_MS`** — the new plotter keys through the app, inside a **pinned**
|
||||||
|
view, so run it with the pause probe, which makes a stable pin
|
||||||
|
(`UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`):
|
||||||
|
|
||||||
|
```
|
||||||
|
[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `_held` must be 1 and both zooms must still happen: "a pinned view is navigable within
|
||||||
|
its data" is the invariant, and a modified zoom must not become its exception.
|
||||||
|
|
||||||
|
- **The status bar**, in the exit dump:
|
||||||
|
|
||||||
|
```
|
||||||
|
status bar: glance=[● live · 10.0 kS/s · 2 s window] max_glance_chars=38
|
||||||
|
detail_fields=11 popover_rebuilds=0
|
||||||
|
```
|
||||||
|
|
||||||
|
**`max_glance_chars` must stay under a small constant** over the whole run — that is "layout
|
||||||
|
must not depend on content" read from the one label still on the bar. **`popover_rebuilds`
|
||||||
|
must be 0** on a run that never opened it.
|
||||||
|
|
||||||
|
- **The plotter's own suite** — `xvfb-run -a deps/plotter/build/debug/tests/plot_tests`, `0
|
||||||
|
failed`, with the two new controller tests.
|
||||||
|
|
||||||
|
- **A full existing run must be unchanged.** The status refactor touches the line every other
|
||||||
|
probe's dump is read beside, so one `UDPSCOPE_EXIT_AFTER_MS` run with
|
||||||
|
`UDPSCOPE_ENABLE_ALL_MS` must still report `gaps=0`, `x-desync 0`, `0 collapsed, 0 staircase`
|
||||||
|
and every toolbar icon `(ok)`.
|
||||||
|
|
||||||
|
`CLAUDE.md` gains these probes, plus invariants for the shared accelerator table, the
|
||||||
|
Shift-is-unexpressible reason behind ctrl/alt, and the one-composition-two-consumers status
|
||||||
|
rule. `README.md` gains the four features and records the deviation from the approved
|
||||||
|
`shift + (+/-)`.
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# The math node editor: wires you can draw and delete, and a canvas you can navigate
|
||||||
|
|
||||||
|
The math channel editor works as a *form* and fails as a *graph editor*. Three things were
|
||||||
|
reported — edges cannot be added, edges cannot be deleted, and the canvas cannot be zoomed or
|
||||||
|
panned — and all three are true. The first is a defect with a specific cause, the second has no
|
||||||
|
code path at all, and the third is an absence. Beyond them the editor asks the user to obey a
|
||||||
|
rule the data structure has (a node may only read nodes above it in the list) rather than
|
||||||
|
maintaining that rule itself, and it reports an invalid graph in a modal alert at the end
|
||||||
|
instead of on the canvas as it is being built.
|
||||||
|
|
||||||
|
This redesigns the canvas interaction. Nothing below changes `MathNode`, `MathChannel`, the
|
||||||
|
evaluator in `src/math_channel.cpp`, the session format, or the producer's path.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In: the canvas view transform, the port and wire hit tests, the gesture state machine
|
||||||
|
(node drag / wire drag / background pan / wheel zoom), edge creation and deletion, a
|
||||||
|
topological re-sort that lets a wire be drawn in either direction, an op picker on the add
|
||||||
|
button, a live validation line that replaces the modal alert, a tidy/auto-layout pass, the
|
||||||
|
`Cancel` button's close path, and the `[math-probe]` assertions for all of it.
|
||||||
|
|
||||||
|
Out: parameters drawn on the node boxes (explicitly declined — the form beside the canvas is
|
||||||
|
where a node's values live). No new ops. No change to `math_channel_valid()`'s rules, to the
|
||||||
|
evaluator, to `MathRuntime`, or to how a derived channel is acquired. No node *library* panel,
|
||||||
|
no copy/paste, no undo.
|
||||||
|
|
||||||
|
## Why edges cannot be added today
|
||||||
|
|
||||||
|
`math_canvas_pressed` (`src/ui.cpp:3455`) handles an output-port hit like this:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const int port = math_port_at(d, x, y, which);
|
||||||
|
if (port == -2) {
|
||||||
|
const int node = math_node_at(d, x, y);
|
||||||
|
d->pending_out = (node >= 0 && d->pending_out == node) ? -1 : node;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`math_port_at` throws away *which* node's output was hit — it returns the sentinel `-2` — and
|
||||||
|
the handler recovers the node with `math_node_at()`, which matches only **inside the box**. The
|
||||||
|
output square is drawn at `x = n.x + kNodeW`, i.e. straddling the right edge: half of it is
|
||||||
|
outside. Its hit radius is `kPort` (±10) while it is drawn at `±kPort * 0.5` (±5), so the
|
||||||
|
clickable region extends 10 px past the edge and `math_node_at()` answers `-1` for every pixel
|
||||||
|
of it beyond the first. `pending_out` is then set to `-1` — a *silent disarm*. Clicking the
|
||||||
|
visible dot's outer half, which is most of what the eye aims at, arms nothing, and the input
|
||||||
|
squares never light up. That is the whole of "it is not possible to add edges": the mechanism
|
||||||
|
exists and the first click of the two-click sequence usually fails.
|
||||||
|
|
||||||
|
It is worth fixing the cause and not only the symptom, because the same re-derivation would
|
||||||
|
break again under the view transform below.
|
||||||
|
|
||||||
|
Edge *deletion* needs no diagnosis: no path in the file ever writes `-1` back into `in_a` or
|
||||||
|
`in_b` except `on_math_node_deleted`, which is deleting the whole node.
|
||||||
|
|
||||||
|
## The view transform
|
||||||
|
|
||||||
|
`MathDialog` gains three doubles:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
double view_scale = 1.0; ///< canvas pixels per graph unit
|
||||||
|
double view_x = 0.0; ///< canvas pixels: where graph (0,0) is drawn
|
||||||
|
double view_y = 0.0;
|
||||||
|
```
|
||||||
|
|
||||||
|
Graph coordinates are what `MathNode::x`/`y` already are and what the session file stores, so a
|
||||||
|
view change is never persisted — reopening the editor shows the graph at 1:1, which is the
|
||||||
|
right default for a window whose size the user did not choose either.
|
||||||
|
|
||||||
|
Two conversions, and **every** hit test goes through the second:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// canvas <- graph: applied once to the cairo context, never per item.
|
||||||
|
cairo_translate(cr, d->view_x, d->view_y);
|
||||||
|
cairo_scale(cr, d->view_scale, d->view_scale);
|
||||||
|
|
||||||
|
// graph <- canvas: the inverse, for a pointer position.
|
||||||
|
void math_to_graph(const MathDialog* d, double cx, double cy, double& gx, double& gy) {
|
||||||
|
gx = (cx - d->view_x) / d->view_scale;
|
||||||
|
gy = (cy - d->view_y) / d->view_scale;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Applying the transform to the context rather than to each item is what keeps the drawing code
|
||||||
|
unchanged: `math_canvas_draw` keeps every coordinate it has today, and the line widths and font
|
||||||
|
sizes scale with the graph, which is what a zoom should do to a diagram.
|
||||||
|
|
||||||
|
**A hit tolerance is a screen distance, not a graph distance.** A port is aimed at with a
|
||||||
|
pointer, so its clickable radius must be the same number of *canvas* pixels at every zoom —
|
||||||
|
which in graph units is `kPort / view_scale`. The same for the wire tolerance below. Getting
|
||||||
|
this backwards makes the ports unhittable when zoomed out, which is exactly when a user is
|
||||||
|
looking for them.
|
||||||
|
|
||||||
|
**Scale is clamped to `[0.25, 4.0]`.** Below a quarter the op names are unreadable and the
|
||||||
|
boxes are smaller than the pointer tolerance; above four a single node fills the canvas. The
|
||||||
|
clamp lives in one place, a `math_set_scale()` helper, because three callers reach it (wheel,
|
||||||
|
Fit, reset).
|
||||||
|
|
||||||
|
### Wheel zoom about the pointer
|
||||||
|
|
||||||
|
A `GtkEventControllerScroll` with `GTK_EVENT_CONTROLLER_SCROLL_VERTICAL`. The anchor is the
|
||||||
|
graph point under the pointer, and it must not move:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
double gx = 0.0, gy = 0.0;
|
||||||
|
math_to_graph(d, px, py, gx, gy); // before
|
||||||
|
d->view_scale = clamp(d->view_scale * (dy < 0 ? 1.1 : 1.0 / 1.1), 0.25, 4.0);
|
||||||
|
d->view_x = px - gx * d->view_scale; // restore the anchor
|
||||||
|
d->view_y = py - gy * d->view_scale;
|
||||||
|
```
|
||||||
|
|
||||||
|
The pointer position comes from the controller's own `GtkEventController` current event rather
|
||||||
|
than from a tracked motion position: a scroll event carries one, and tracking motion to supply
|
||||||
|
it is a second source of truth that can be stale.
|
||||||
|
|
||||||
|
No modifier is involved. The canvas has no other use for the wheel, so a bare wheel zoom is
|
||||||
|
unambiguous here — unlike the plot, where the wheel's modifiers are already spoken for.
|
||||||
|
|
||||||
|
### Pan, Fit, and reset
|
||||||
|
|
||||||
|
Dragging the empty background pans: `view_x = pan_x0 + dx`, from a `pan_x0` recorded at
|
||||||
|
`drag-begin`, for the same total-offset reason the node drag already documents at
|
||||||
|
`src/ui.cpp:3507`.
|
||||||
|
|
||||||
|
A **Fit** button frames every node:
|
||||||
|
|
||||||
|
```
|
||||||
|
bbox over all math_node_rect() (empty graph -> identity view)
|
||||||
|
scale = clamp(min(w_canvas / (bbox.w + 2*pad), h_canvas / (bbox.h + 2*pad)), 0.25, 4.0)
|
||||||
|
view_* = centre the scaled bbox in the canvas
|
||||||
|
```
|
||||||
|
|
||||||
|
`Ctrl+0` resets to `scale = 1`, `view_x = view_y = 0`. Both are one call to a shared
|
||||||
|
`math_fit_view()` / `math_reset_view()` so the button and the accelerator cannot drift apart.
|
||||||
|
|
||||||
|
## The port and wire hit tests
|
||||||
|
|
||||||
|
`math_port_at` is replaced by one that **names the node it found**:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// The port under a *graph* position. Returns the node index, with `which` set to
|
||||||
|
/// `kPortOut` (-1) for its output or 0/1 for an input; `-1` when no port is under it.
|
||||||
|
int math_port_at(const MathDialog* d, double gx, double gy, double tol, int& which);
|
||||||
|
```
|
||||||
|
|
||||||
|
Two changes beyond the signature. It scans **backward**, topmost first, so it agrees with
|
||||||
|
`math_node_at()` about which of two overlapping nodes owns a shared pixel — a disagreement
|
||||||
|
between the two searches is how a click lands on one node and a drag on another. And the
|
||||||
|
caller never re-derives the owner, which is the defect above made structurally impossible.
|
||||||
|
|
||||||
|
The hit radius stays *larger* than the drawn square: ±`kPort` canvas pixels around the port's
|
||||||
|
centre, against a square drawn at ±`kPort * 0.5` — a 20 px target for a 10 px mark. That is
|
||||||
|
deliberate and, once the owner is no longer re-derived, harmless: a generous radius is the
|
||||||
|
difference between a diagram that can be wired and one that cannot. The asymmetry is written
|
||||||
|
down in a comment at the hit test, because it is what the old defect fed on.
|
||||||
|
|
||||||
|
A wire is picked by distance to the curve it is drawn as. The curve is the same cubic the
|
||||||
|
renderer uses (`src/ui.cpp:3401`), so the pick and the ink cannot disagree:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// The wire under a graph position, as (consumer node, which input), or {-1, 0}.
|
||||||
|
/// The bezier is sampled at 24 points and the nearest segment measured; a curve 40 units of
|
||||||
|
/// tread wide over a node pitch of 160 is smooth at that sampling, and the tolerance is 6
|
||||||
|
/// canvas pixels, so a miss by a pixel is not a wrong edge deleted.
|
||||||
|
MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol);
|
||||||
|
```
|
||||||
|
|
||||||
|
Sampling rather than solving: the exact distance to a cubic is a fifth-degree root-find, and 24
|
||||||
|
samples of a curve whose control points are 40 units out is well inside a 6 px tolerance. The
|
||||||
|
sample count is a named constant so a future wider tread can raise it.
|
||||||
|
|
||||||
|
**Order of precedence on a press**: port, then wire, then node body, then background. A port
|
||||||
|
sits on a node's edge and a wire ends on a port, so each later test must only see what the
|
||||||
|
earlier ones refused.
|
||||||
|
|
||||||
|
## The gesture state machine
|
||||||
|
|
||||||
|
One `GtkGestureDrag` serves four gestures, decided entirely by what the press landed on. A
|
||||||
|
`GtkGestureClick` is kept for the selection-only cases, because a click is a drag of zero
|
||||||
|
length and GTK delivers both; `drag-begin` records the mode and `pressed` only ever *selects*.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
enum class MathDrag { None, Node, Wire, Pan };
|
||||||
|
MathDrag drag_mode = MathDrag::None;
|
||||||
|
int wire_from = -1; ///< Wire: the node whose output the live wire leaves
|
||||||
|
int wire_detach = -1; ///< Wire: the consumer whose input was detached to start it, or -1
|
||||||
|
int wire_detach_which = 0;
|
||||||
|
double wire_x = 0.0; ///< Wire: the pointer, in graph units, for the preview
|
||||||
|
double wire_y = 0.0;
|
||||||
|
double pan_x0 = 0.0;
|
||||||
|
double pan_y0 = 0.0;
|
||||||
|
MathWireRef selected_wire{-1, 0};
|
||||||
|
```
|
||||||
|
|
||||||
|
`drag-begin`, in precedence order:
|
||||||
|
|
||||||
|
- **An output port** → `Wire`, `wire_from = node`, `wire_detach = -1`.
|
||||||
|
- **A wired input port** → `Wire`, `wire_from = <the node it was wired from>`,
|
||||||
|
`wire_detach = node`, `wire_detach_which = which`, and the input is cleared *immediately*.
|
||||||
|
Dragging an existing wire off its input and dropping it elsewhere is one gesture, and
|
||||||
|
clearing on begin is what makes the preview show the truth: the wire the user is holding.
|
||||||
|
Dropping it on nothing therefore *detaches*, which is the agreed detach gesture, and needs
|
||||||
|
no separate case.
|
||||||
|
- **An unwired input port** → `None` (select the node; there is nothing to drag from an empty
|
||||||
|
input). Wiring is always output→input, so this is not a connect gesture.
|
||||||
|
- **A node body** → `Node`, exactly as today.
|
||||||
|
- **Anything else** → `Pan`.
|
||||||
|
|
||||||
|
`drag-update` moves the node, the live wire's endpoint, or the view, by mode.
|
||||||
|
|
||||||
|
`drag-end` resolves `Wire`:
|
||||||
|
|
||||||
|
- Released on an **input port** of a node that is not `wire_from` → `math_connect()`.
|
||||||
|
- Released on an **input port of `wire_from` itself** → refused, with "a node cannot read
|
||||||
|
itself" on the validation line. A self-edge is the one cycle a topological sort cannot
|
||||||
|
break, and refusing it by name reads better than "the graph has a cycle".
|
||||||
|
- Released anywhere else → nothing further. If the drag began by detaching, the detachment
|
||||||
|
stands.
|
||||||
|
|
||||||
|
`Escape` during a `Wire` drag cancels and **restores** a detached input: a cancel must undo the
|
||||||
|
clear-on-begin, or Escape would be a way to delete an edge by accident.
|
||||||
|
|
||||||
|
A `GtkEventControllerKey` on the canvas handles `Delete`/`BackSpace` (clear `selected_wire`),
|
||||||
|
`Escape` (cancel a drag, else clear the wire selection) and `Ctrl+0` (reset the view). It is
|
||||||
|
added to the canvas and the canvas is given `gtk_widget_set_focusable(TRUE)` plus focus on
|
||||||
|
press, because a drawing area is not focusable by default and a key controller on an unfocused
|
||||||
|
widget never fires. **The key controller must not swallow text**: the dialog's name and unit
|
||||||
|
entries are `GtkEditable`s, and the controller is on the *canvas*, so a key only reaches it
|
||||||
|
while the canvas has focus — the same reasoning as the app-level accelerators' bubble phase,
|
||||||
|
reached by a narrower route.
|
||||||
|
|
||||||
|
## The edge model
|
||||||
|
|
||||||
|
One function owns connection:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// Wire `from`'s output into `to`'s input `which`. Re-sorts the node list when the wire runs
|
||||||
|
/// backwards. Returns false and sets `why` when the wire would make a cycle.
|
||||||
|
bool math_connect(MathDialog* d, int from, int to, int which, std::string* why);
|
||||||
|
```
|
||||||
|
|
||||||
|
and deletion is a one-liner at the call site (`in_a`/`in_b` back to `-1`), because there is
|
||||||
|
nothing to validate in removing an edge: a disconnected input is a state the validation line
|
||||||
|
already names.
|
||||||
|
|
||||||
|
### Backwards wires re-sort the list
|
||||||
|
|
||||||
|
`MathChannel::nodes` is a list walked in order, and the evaluator's correctness rests on a node
|
||||||
|
reading only nodes before it. Today that rule is the *user's* problem: the input ports of nodes
|
||||||
|
above the armed output are not even drawn as armed, so a wire that would run backwards cannot
|
||||||
|
be drawn at all. In a two-column auto-placed graph that is a real obstruction — the node you
|
||||||
|
want to feed is frequently the one that happens to sit earlier in the list.
|
||||||
|
|
||||||
|
The rule becomes the editor's invariant instead. `math_connect()` on a backwards wire
|
||||||
|
(`from > to`) builds the new edge set, runs a topological sort, and commits only if it is
|
||||||
|
acyclic:
|
||||||
|
|
||||||
|
1. Trial edge set = every node's existing inputs, plus the new one.
|
||||||
|
2. Kahn's algorithm over it. Ties broken by **current list index**, so a sort that changes
|
||||||
|
nothing leaves the list alone and a sort that must move something moves as little as
|
||||||
|
possible. (A stable order matters: the node positions on the canvas do not change, so a
|
||||||
|
gratuitous renumber would silently re-point `output` and `selected` for no reason the user
|
||||||
|
can see.)
|
||||||
|
3. On a cycle: nothing is written, `why` is set, and the validation line shows it. The edge is
|
||||||
|
*not* created — this is the one refusal left, and it is the honest one.
|
||||||
|
4. On success: permute `nodes`, then remap through the permutation **everything that holds an
|
||||||
|
index** — `output`, `selected`, every `in_a`/`in_b`, `drag_node`, `selected_wire.node`. A
|
||||||
|
forgotten one is a silent mis-wire, so they are remapped in a single block with a comment
|
||||||
|
naming the set.
|
||||||
|
|
||||||
|
The session format is untouched: what is written is still a list in evaluation order. A file
|
||||||
|
written by an older build still loads, because the sort is an editor operation and an
|
||||||
|
already-ordered list sorts to itself.
|
||||||
|
|
||||||
|
`math_canvas_draw` stops gating a wire on `from < i` once this holds — but keeps the gate as a
|
||||||
|
**defensive skip**, because a `MathChannel` loaded from a file the editor did not write could
|
||||||
|
hold a forward reference, and drawing it would be drawing an edge the evaluator ignores.
|
||||||
|
|
||||||
|
### The armed-port highlight goes away
|
||||||
|
|
||||||
|
`pending_out` and the "input ports of later nodes light up" highlight are both deleted. They
|
||||||
|
exist to serve the two-click protocol and the list-order rule, and neither survives: with a
|
||||||
|
drag gesture the feedback is the live wire following the pointer, and with the re-sort every
|
||||||
|
input is a legal target. What replaces the highlight is a hover accent on the port under the
|
||||||
|
pointer during a `Wire` drag, which says the one thing that still matters — whether releasing
|
||||||
|
here will connect.
|
||||||
|
|
||||||
|
## The live validation line
|
||||||
|
|
||||||
|
`on_math_apply` validates in two steps today (`math_channel_valid()`, then an acquired-source
|
||||||
|
check the validator cannot make) and reports each in a modal `GtkAlertDialog`. A modal alert at
|
||||||
|
the end of a build is the worst time and place to be told; the graph is right there and can say
|
||||||
|
so continuously.
|
||||||
|
|
||||||
|
One extraction:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// Why the graph as it stands could not be saved, or "" when it could. Both checks the Apply
|
||||||
|
/// path made: `math_channel_valid()`'s graph rules and the dialog's own "a Source must name an
|
||||||
|
/// acquired channel", which the validator cannot make because only the dialog sees that list.
|
||||||
|
std::string math_dialog_problem(const MathDialog* d);
|
||||||
|
```
|
||||||
|
|
||||||
|
`math_dialog_sync()` — already the one place that rewrites the form from the model, and already
|
||||||
|
called from every mutation — sets the line's text and `gtk_widget_set_sensitive(apply, problem
|
||||||
|
.empty())`. An insensitive Apply with the reason beside it is a better refusal than a dialog
|
||||||
|
that must be dismissed.
|
||||||
|
|
||||||
|
`on_math_apply` keeps the check and loses the **dialogs**: it calls `math_dialog_problem()` and
|
||||||
|
returns on a non-empty answer, with no `GtkAlertDialog`. The guard has to stay even though the
|
||||||
|
button cannot be clicked while it would fire, because the probe calls `on_math_apply()` directly
|
||||||
|
and must not be able to save a broken graph through it. That `d->apply` pointer is new on
|
||||||
|
`MathDialog`; the button is built after the canvas today, so `math_dialog_sync()`'s first call
|
||||||
|
(at `src/ui.cpp:3972`, after the whole tree) already runs late enough to set it.
|
||||||
|
|
||||||
|
The line is a `GtkLabel` under the canvas, `error` CSS class when non-empty, and
|
||||||
|
`gtk_label_set_ellipsize(END)` with a tooltip carrying the full text — **the layout-must-not-
|
||||||
|
depend-on-content rule applies here as much as to the status bar**: this label sits in a dialog
|
||||||
|
whose canvas is `hexpand`, and a wrapping error message would otherwise widen the dialog on a
|
||||||
|
mere change of graph.
|
||||||
|
|
||||||
|
## The op picker on add
|
||||||
|
|
||||||
|
`add node` always makes a `MathOp::Gain` (`src/ui.cpp:3516`), so adding a low pass is two
|
||||||
|
actions and a dropdown hunt. It becomes a `GtkMenuButton` labelled `add` whose popover holds a
|
||||||
|
flat button per op, built once from `math_op_count()` / `math_op_at(i)` — the accessor order,
|
||||||
|
not the enum order, for the reason `CLAUDE.md` already records about writing an op's index.
|
||||||
|
Each button carries its op index with `g_object_set_data`, so there is no action-name plumbing
|
||||||
|
and nothing to parse.
|
||||||
|
|
||||||
|
The two behaviours that already work are kept: the new node becomes the output when the graph
|
||||||
|
`was_linear`, and placement is the existing grid. Placement gains one thing — a node is placed
|
||||||
|
at the **first free grid cell** rather than at `nodes.size()`, so the slot left by a deletion is
|
||||||
|
reused instead of the grid growing for ever. The overlap rule stands (`CLAUDE.md`: boxes must
|
||||||
|
not overlap, or `math_node_at()`'s topmost-first search returns the wrong one).
|
||||||
|
|
||||||
|
## Tidy / auto-layout
|
||||||
|
|
||||||
|
A `tidy` button lays the graph out left to right by dependency depth:
|
||||||
|
|
||||||
|
```
|
||||||
|
depth(i) = 0 for a Source or an unwired node
|
||||||
|
= 1 + max(depth of its wired inputs) // well-defined: inputs precede i
|
||||||
|
x = 24 + depth * (kNodeW + 48)
|
||||||
|
y = 20 + row_within_depth * (kNodeH + 22)
|
||||||
|
```
|
||||||
|
|
||||||
|
It needs no recursion and no visited set: the list is in evaluation order — the invariant the
|
||||||
|
re-sort now maintains — so the depth of node `i` depends only on nodes before it and a single
|
||||||
|
forward pass computes every depth. Then `math_fit_view()`, because a tidied graph that is off
|
||||||
|
screen is not tidy.
|
||||||
|
|
||||||
|
The column pitch is `kNodeW + 48`, which is 180 against the 160 the current grid uses: 160
|
||||||
|
leaves 28 units for the wire's 40-unit tread, so the curve doubles back on itself between
|
||||||
|
adjacent columns.
|
||||||
|
|
||||||
|
## The `Cancel` button closes rather than destroys
|
||||||
|
|
||||||
|
`src/ui.cpp:3962` connects `Cancel` to `gtk_window_destroy`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the defect `CLAUDE.md` documents under the math channel invariants — "a Cancel that
|
||||||
|
*destroyed* the window left `app.math_dialog` pointing at a dead widget: every later
|
||||||
|
`open_math_dialog()` took the 'already open' branch and presented it" — and it is still live.
|
||||||
|
The fix for it reached the Apply path (`src/ui.cpp:3731`) and the probe (`src/ui.cpp:6672`) and
|
||||||
|
never reached the button. It is `gtk_window_close`, one word, and the probe must assert it,
|
||||||
|
which it does not today: it only ever closes programmatically.
|
||||||
|
|
||||||
|
## What `[math-probe]` must assert
|
||||||
|
|
||||||
|
Every gesture above is reachable from the probe, because the handlers take the `MathDialog*`
|
||||||
|
and plain coordinates — which is already how the drag test at `src/ui.cpp:6694` works. The
|
||||||
|
probe is extended with one line per claim; each is an assertion stated as a `want` beside the
|
||||||
|
reading, in the style of the existing lines.
|
||||||
|
|
||||||
|
- `[math-probe] hit: output at edge+1 -> node N which=-1, at edge+9 -> node N which=-1
|
||||||
|
(want N N)` — the silent-disarm defect, read at two offsets inside the hit radius and outside
|
||||||
|
the box. Both must name the node. **This is the one line that would have caught the reported
|
||||||
|
bug**, and it is the reason the hit test returns the node rather than a sentinel.
|
||||||
|
- `[math-probe] wire: connect 0->1.a wired=W (want 0)` then a backwards
|
||||||
|
`[math-probe] wire: backwards connect 2->0.a order=[..] wired=W reordered=D (want 1)` — the
|
||||||
|
re-sort, with the permuted op order printed so a wrong remap is visible as a wrong list.
|
||||||
|
- `[math-probe] wire: self refused=D problem=[..] (want 1)` and
|
||||||
|
`[math-probe] wire: cycle refused=D problem=[..] (want 1)` — the two refusals, with the
|
||||||
|
cycle built by wiring a two-node pair both ways. `refused=1` **and** the edge absent.
|
||||||
|
- `[math-probe] wire: pick at (x,y) -> (node,which)=(N,K) delete -> in=-1 (want -1)` — the
|
||||||
|
pick samples the curve at its midpoint, which is the one place a wrong tolerance or a wrong
|
||||||
|
curve shows.
|
||||||
|
- `[math-probe] detach: drag from in 1.a to empty -> in=-1 (want -1)`, and
|
||||||
|
`[math-probe] detach: escape restores in=F (want F)` — the clear-on-begin and its undo, which
|
||||||
|
is the half that silently deletes an edge if Escape is forgotten.
|
||||||
|
- `[math-probe] view: zoom scale=S anchor kept dx=.. dy=.. (want 0.0 0.0)` — the graph point
|
||||||
|
under the pointer before and after a wheel step, which is the only reading that distinguishes
|
||||||
|
"zoomed about the pointer" from "zoomed about the origin". Both must be zero to a rounding.
|
||||||
|
- `[math-probe] view: fit scale=S box=[..] all_inside=D (want 1)`, and
|
||||||
|
`[math-probe] view: reset scale=S offset=(..) (want 1.000 (0,0))`.
|
||||||
|
- `[math-probe] view: pan dx=.. dy=.. offset=(..) (want ..)` plus a hit test **after** a pan and
|
||||||
|
a zoom — `port at the same screen point -> node N` — because a hit test that forgot the
|
||||||
|
inverse transform passes every test taken at the identity view.
|
||||||
|
- `[math-probe] tidy: depths=[..] columns=[..] overlaps=D (want 0)` — the overlap count is the
|
||||||
|
assertion (`CLAUDE.md`'s own rule), and the depths say the layout followed the graph.
|
||||||
|
- `[math-probe] validate: problem=[..] apply_sensitive=D` before and after wiring the missing
|
||||||
|
input: `(want non-empty 0)` then `(want empty 1)`.
|
||||||
|
- `[math-probe] cancel: dialog=cleared` — the `Cancel` button's `::clicked` emitted directly,
|
||||||
|
then `app.math_dialog` read back. `STILL SET` is the regression.
|
||||||
|
|
||||||
|
Two things the probe deliberately cannot assert, and which are therefore stated as design
|
||||||
|
constraints rather than checks: that the canvas takes **focus** on press (a synthetic press
|
||||||
|
does not run GTK's focus machinery), and that the live wire *looks* like it follows the pointer
|
||||||
|
(a draw is not readable from a probe). The second is bounded by the first assertion set — the
|
||||||
|
endpoint it is drawn from is `wire_x`/`wire_y`, which the drag test writes and can print.
|
||||||
|
|
||||||
|
The existing lines are all kept. `drag:` in particular stays as it is: the total-offset rule is
|
||||||
|
unchanged by any of this, and the gesture that exercises it is still a node-body drag.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Three places, following the project's existing split:
|
||||||
|
|
||||||
|
- **`CLAUDE.md`** — the `[math-probe]` bullet under Verification gains the new readings and
|
||||||
|
their `want`s; the math channel invariants gain the **hit-test-names-its-own-node** rule (a
|
||||||
|
port hit must not be re-derived with a box test), the **tolerance-is-a-screen-distance** rule,
|
||||||
|
the **list order is the editor's invariant, maintained by a topological re-sort** rule with
|
||||||
|
the index-remap set named, and a note that the `Cancel` path is now asserted.
|
||||||
|
- **`README.md`** — a subsection under the math channels describing the editor as it now
|
||||||
|
behaves, and the diagnosis of the silent disarm, since README is where "what was actually
|
||||||
|
built and why" lives.
|
||||||
|
- **`src/help/11-math.txt`** gains the four canvas gestures (drag a port to wire, drag a wired
|
||||||
|
input away to detach, click a wire and press Delete, wheel to zoom and drag the background to
|
||||||
|
pan) and the two buttons. It is an edit to an existing page, so `pages` stays 14 and
|
||||||
|
`[help] pages=14 parsed=14` must still hold — the markup is Pango and a page that fails to
|
||||||
|
parse renders a stand-in.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **The re-sort touching an index nothing remapped.** The mitigation is that the remap is one
|
||||||
|
block, the probe prints the permuted op order, and `selected`/`output` are both read back.
|
||||||
|
- **The key controller stealing a keystroke from the name entry.** It is on the canvas, which
|
||||||
|
must have focus; the probe cannot see this, so it is checked by hand once, by typing in the
|
||||||
|
name field with a wire selected.
|
||||||
|
- **The wheel controller fighting a future scrolled window.** There is none and this design
|
||||||
|
removes the reason for one, which is worth stating so a later "wrap the canvas in a
|
||||||
|
`GtkScrolledWindow`" is recognised as undoing the zoom rather than adding to it.
|
||||||
|
- **`GtkGestureDrag` and `GtkGestureClick` on one widget both claiming a press.** They already
|
||||||
|
coexist on this canvas; the change is that `pressed` now only selects and never mutates the
|
||||||
|
graph, which removes the ordering question rather than answering it.
|
||||||
@@ -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,90 @@
|
|||||||
|
<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>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>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
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,99 @@
|
|||||||
|
<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>, or pick <gui>Layout editor</gui> from the overflow 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, 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="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,159 @@
|
|||||||
|
<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>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, a <em>time</em> mark is a marker at a position. 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="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,79 @@
|
|||||||
|
<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
|
||||||
|
details directly under its name — <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,72 @@
|
|||||||
|
<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>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,288 @@
|
|||||||
|
<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>/</file>) 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>
|
||||||
+775
-163
File diff suppressed because it is too large.
Load diff
+167
-20
@@ -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,13 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace udpscope::build_info {
|
||||||
|
|
||||||
|
/// The CMake project version, e.g. "0.1.0".
|
||||||
|
extern const char* const kVersion;
|
||||||
|
/// `git describe --always --dirty` at build time, or "unknown" if git was not available.
|
||||||
|
extern const char* const kCommit;
|
||||||
|
/// When the binary was configured/built, as "YYYY-MM-DD HH:MM UTC".
|
||||||
|
extern const char* const kTimestamp;
|
||||||
|
|
||||||
|
} // namespace udpscope::build_info
|
||||||
+238
@@ -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
|
||||||
+124
@@ -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
|
||||||
+62
-40
@@ -24,51 +24,73 @@ const plot::data::MinMaxPyramid& null_pyramid() {
|
|||||||
// --- ChannelBuffer ----------------------------------------------------------
|
// --- ChannelBuffer ----------------------------------------------------------
|
||||||
|
|
||||||
ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
ChannelBuffer::ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
||||||
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride,
|
std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride,
|
||||||
f64 epoch, f64 nominal_dt)
|
f64 epoch, f64 nominal_dt)
|
||||||
: values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride),
|
: values_(std::move(values)), times_(std::move(times)), stride_(stride < 1 ? 1 : stride),
|
||||||
epoch_(epoch), dt_(nominal_dt) {}
|
epoch_(epoch), dt_(nominal_dt) {}
|
||||||
|
|
||||||
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
|
||||||
|
// not being published for a while -- makes the incremental append meaningless:
|
||||||
|
// the samples between `published_` and `produced_` are no longer in the ring.
|
||||||
|
// Rebuilding from the retained window is O(capacity) once, and it is the only
|
||||||
|
// way the published window stays coherent (an empty or garbled window is what
|
||||||
|
// "the trace disappeared" looks like from outside).
|
||||||
|
if (produced - published_ > values_->capacity()) {
|
||||||
|
published_ = 0;
|
||||||
|
pyramid_.clear();
|
||||||
|
}
|
||||||
|
f32 chunk[kChunk];
|
||||||
|
for (u64 i = published_; i < produced;) {
|
||||||
|
const u64 take = std::min<u64>(kChunk, produced - i);
|
||||||
|
values_->copy_from(i, take, chunk);
|
||||||
|
for (u64 k = 0; k < take; ++k) pyramid_.append(chunk[k]);
|
||||||
|
i += take;
|
||||||
|
}
|
||||||
|
published_ = produced;
|
||||||
|
// The pyramid's window follows the ring's: sample index `base` is the oldest
|
||||||
|
// one still retained. drop_before() is monotonic and cheap.
|
||||||
|
pyramid_.drop_before(values_->base());
|
||||||
|
// And the window's first stamp with it, here and nowhere else (see the member's note): the
|
||||||
|
// 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.
|
||||||
|
if (pyramid_.size() > 0) window_x0_ = time_of(absolute(0));
|
||||||
}
|
}
|
||||||
// A producer that lapped this ring -- a long trigger wait, or a store that was
|
// Backfills, applied after the append pass above: an amend is only ever queued for a slot
|
||||||
// not being published for a while -- makes the incremental append meaningless:
|
// the value ring already produced, so the append has reached it and the pyramid's bounds
|
||||||
// the samples between `published_` and `produced_` are no longer in the ring.
|
// test will accept it. Unconditional, outside the `produced > published_` guard -- a late
|
||||||
// Rebuilding from the retained window is O(capacity) once, and it is the only
|
// packet can land in a frame interval that pushed nothing new, and the plan's placement
|
||||||
// way the published window stays coherent (an empty or garbled window is what
|
// behind the old early return would have left the pyramid stale until the next push.
|
||||||
// "the trace disappeared" looks like from outside).
|
if (!pending_amends_.empty()) {
|
||||||
if (produced - published_ > values_->capacity()) {
|
for (const Amend& a : pending_amends_) pyramid_.amend(a.absolute, a.value);
|
||||||
published_ = 0;
|
pending_amends_.clear();
|
||||||
pyramid_.clear();
|
|
||||||
}
|
}
|
||||||
f32 chunk[kChunk];
|
|
||||||
for (u64 i = published_; i < produced;) {
|
|
||||||
const u64 take = std::min<u64>(kChunk, produced - i);
|
|
||||||
values_->copy_from(i, take, chunk);
|
|
||||||
for (u64 k = 0; k < take; ++k) pyramid_.append(chunk[k]);
|
|
||||||
i += take;
|
|
||||||
}
|
|
||||||
published_ = produced;
|
|
||||||
// The pyramid's window follows the ring's: sample index `base` is the oldest
|
|
||||||
// one still retained. drop_before() is monotonic and cheap.
|
|
||||||
pyramid_.drop_before(values_->base());
|
|
||||||
// And the window's first stamp with it, here and nowhere else (see the member's note): the
|
|
||||||
// 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.
|
|
||||||
if (pyramid_.size() > 0) window_x0_ = time_of(absolute(0));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void ChannelBuffer::invalidate() noexcept {
|
void ChannelBuffer::invalidate() noexcept {
|
||||||
published_ = 0;
|
published_ = 0;
|
||||||
window_x0_ = 0.0f;
|
window_x0_ = 0.0;
|
||||||
pyramid_.clear();
|
pyramid_.clear();
|
||||||
|
pending_amends_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
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 {
|
||||||
// Channel sample `j` is produced on the tick that also pushes time slot
|
// Channel sample `j` is produced on the tick that also pushes time slot
|
||||||
// `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the
|
// `j*stride + stride - 1` (see `Acquisition::on_data`): the producer stamps the
|
||||||
// time ring every `time_stride` ticks and the channel every `stride` of those,
|
// time ring every `time_stride` ticks and the channel every `stride` of those,
|
||||||
@@ -102,10 +124,10 @@ f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
|||||||
// whole trace's geometry collapsing.
|
// whole trace's geometry collapsing.
|
||||||
const u64 base = times_->base();
|
const u64 base = times_->base();
|
||||||
const u64 produced = times_->produced();
|
const u64 produced = times_->produced();
|
||||||
if (produced == 0) return 0.0f;
|
if (produced == 0) return 0.0;
|
||||||
u64 slot = index < base ? base : index;
|
u64 slot = index < base ? base : index;
|
||||||
if (slot >= produced) slot = produced - 1;
|
if (slot >= produced) slot = produced - 1;
|
||||||
f32 out = 0.0f;
|
f64 out = 0.0;
|
||||||
times_->copy_from(slot, 1, &out);
|
times_->copy_from(slot, 1, &out);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -121,7 +143,7 @@ f64 ChannelBuffer::dx() const {
|
|||||||
|
|
||||||
f64 ChannelBuffer::x0() const {
|
f64 ChannelBuffer::x0() const {
|
||||||
if (pyramid_.size() == 0) return epoch_;
|
if (pyramid_.size() == 0) return epoch_;
|
||||||
return epoch_ + f64(window_x0_);
|
return epoch_ + window_x0_;
|
||||||
}
|
}
|
||||||
|
|
||||||
f64 ChannelBuffer::x_at(u64 i) const {
|
f64 ChannelBuffer::x_at(u64 i) const {
|
||||||
@@ -131,7 +153,7 @@ f64 ChannelBuffer::x_at(u64 i) const {
|
|||||||
// ring-span too new made it refuse the whole visible range -- which left the renderer
|
// ring-span too new made it refuse the whole visible range -- which left the renderer
|
||||||
// gathering the entire ring at a coarse level and drawing it off the axis.
|
// gathering the entire ring at a coarse level and drawing it off the axis.
|
||||||
if (i == 0) return x0();
|
if (i == 0) return x0();
|
||||||
return epoch_ + f64(time_of(absolute(i)));
|
return epoch_ + time_of(absolute(i));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const {
|
bool ChannelBuffer::neighbours(f64 x, u64& i0, u64& i1) const {
|
||||||
@@ -171,7 +193,7 @@ u64 ChannelBuffer::snap_index(f64 x) const {
|
|||||||
plot::Range ChannelBuffer::x_bounds() const {
|
plot::Range ChannelBuffer::x_bounds() const {
|
||||||
const u64 n = pyramid_.size();
|
const u64 n = pyramid_.size();
|
||||||
if (n == 0) return plot::Range{epoch_, epoch_};
|
if (n == 0) return plot::Range{epoch_, epoch_};
|
||||||
return plot::Range{x0(), epoch_ + f64(time_of(absolute(n - 1)))};
|
return plot::Range{x0(), epoch_ + time_of(absolute(n - 1))};
|
||||||
}
|
}
|
||||||
|
|
||||||
plot::Range ChannelBuffer::y_bounds() const {
|
plot::Range ChannelBuffer::y_bounds() const {
|
||||||
@@ -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 {
|
||||||
|
|||||||
+45
-12
@@ -34,8 +34,10 @@ using plot::u64;
|
|||||||
/// undecimated case and the common one.
|
/// undecimated case and the common one.
|
||||||
class ChannelBuffer final : public plot::data::Series {
|
class ChannelBuffer final : public plot::data::Series {
|
||||||
public:
|
public:
|
||||||
|
/// `values` is f32 and `times` is f64: see `BufferStore::time()` for why the clock
|
||||||
|
/// alone needs the wider type.
|
||||||
ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
ChannelBuffer(std::shared_ptr<const plot::data::RingBuffer<f32>> values,
|
||||||
std::shared_ptr<const plot::data::RingBuffer<f32>> times, u64 stride, f64 epoch,
|
std::shared_ptr<const plot::data::RingBuffer<f64>> times, u64 stride, f64 epoch,
|
||||||
f64 nominal_dt);
|
f64 nominal_dt);
|
||||||
|
|
||||||
ChannelBuffer(const ChannelBuffer&) = delete;
|
ChannelBuffer(const ChannelBuffer&) = delete;
|
||||||
@@ -46,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
|
||||||
@@ -71,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
|
||||||
@@ -94,10 +119,17 @@ class ChannelBuffer final : public plot::data::Series {
|
|||||||
/// Absolute (channel) index of retained-window index `i`.
|
/// Absolute (channel) index of retained-window index `i`.
|
||||||
u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; }
|
u64 absolute(u64 i) const noexcept { return pyramid_.window_base() + i; }
|
||||||
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
|
/// Timestamp of channel sample `absolute_index`, in seconds since `epoch_`.
|
||||||
f32 time_of(u64 absolute_index) const noexcept;
|
f64 time_of(u64 absolute_index) const noexcept;
|
||||||
|
|
||||||
|
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<f32>> times_;
|
std::shared_ptr<const plot::data::RingBuffer<f64>> times_;
|
||||||
u64 stride_ = 1;
|
u64 stride_ = 1;
|
||||||
f64 epoch_ = 0.0;
|
f64 epoch_ = 0.0;
|
||||||
f64 dt_ = 1.0;
|
f64 dt_ = 1.0;
|
||||||
@@ -107,14 +139,15 @@ 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
|
||||||
/// *right* answer rather than merely a stable one: it is the stamp sample `window_base`
|
/// *right* answer rather than merely a stable one: it is the stamp sample `window_base`
|
||||||
/// actually carries, where the clamp answered with whatever slot happened to be oldest now.
|
/// actually carries, where the clamp answered with whatever slot happened to be oldest now.
|
||||||
f32 window_x0_ = 0.0f;
|
f64 window_x0_ = 0.0;
|
||||||
plot::data::MinMaxPyramid pyramid_;
|
plot::data::MinMaxPyramid pyramid_;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -161,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
|
||||||
+103
-6
@@ -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) {
|
||||||
@@ -67,6 +126,7 @@ Config parse_args(int argc, char** argv) {
|
|||||||
std::exit(0);
|
std::exit(0);
|
||||||
} else if (a == "--server" || a.rfind("--server=", 0) == 0) {
|
} else if (a == "--server" || a.rfind("--server=", 0) == 0) {
|
||||||
c.server = value_of(argc, argv, i, "--server");
|
c.server = value_of(argc, argv, i, "--server");
|
||||||
|
c.server_specified = true;
|
||||||
} else if (a == "--port" || a.rfind("--port=", 0) == 0) {
|
} else if (a == "--port" || a.rfind("--port=", 0) == 0) {
|
||||||
const std::string v = value_of(argc, argv, i, "--port");
|
const std::string v = value_of(argc, argv, i, "--port");
|
||||||
const long p = std::strtol(v.c_str(), nullptr, 10);
|
const long p = std::strtol(v.c_str(), nullptr, 10);
|
||||||
@@ -76,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");
|
||||||
@@ -97,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);
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-4
@@ -6,19 +6,55 @@
|
|||||||
#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).
|
||||||
std::string multicast; ///< Empty selects unicast.
|
std::string multicast; ///< Empty selects unicast.
|
||||||
std::string interface_addr; ///< Local IPv4 to join the group on; empty = default route.
|
std::string interface_addr; ///< Local IPv4 to join the group on; empty = default route.
|
||||||
|
|
||||||
/// Size of *each* of the two sample buffers, in megabytes. The application
|
/// Whether `--server` was given. Absent, the application asks which source to open rather
|
||||||
/// always allocates two identical buffers (spec item 1).
|
/// than silently taking the default above -- so this is "did the user choose", not "is
|
||||||
double buffer_mb = 200.0;
|
/// `server` valid". The default stays a usable value for every other path.
|
||||||
|
bool server_specified = false;
|
||||||
|
|
||||||
|
/// `--source NAME`: which registered kind opens. `udps` is the default, and `--server/--port/
|
||||||
|
/// --multicast/--interface` keep meaning UDPS.
|
||||||
|
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).
|
||||||
@@ -30,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
|
||||||
|
|||||||
+56
-13
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
@@ -26,8 +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 {
|
||||||
std::vector<f32> stamps; ///< edge-relative seconds, monotonic
|
/// Seconds, monotonic, and **in the coordinates the axis reads**: a trigger event's
|
||||||
|
/// stamps are edge-relative and a pause view's are relative to the instant it froze,
|
||||||
|
/// which is what `x_offset` below records. **f64, like the live ring it is cut from**
|
||||||
|
/// (see `BufferStore::time()`), which also makes the cut in `publish_event()`/
|
||||||
|
/// `publish_pause_view()` a straight copy.
|
||||||
|
std::vector<f64> stamps;
|
||||||
|
/// 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
|
||||||
@@ -43,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.
|
||||||
@@ -102,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;
|
||||||
@@ -112,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,360 @@
|
|||||||
|
// 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; }
|
||||||
|
|
||||||
|
EventStore::EventStore(std::string source, std::string base_dir) {
|
||||||
|
if (source.empty()) return;
|
||||||
|
std::string safe;
|
||||||
|
for (char c : source) {
|
||||||
|
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||||
|
c == '.' || c == '_' || c == '-';
|
||||||
|
safe.push_back(ok ? c : '_');
|
||||||
|
}
|
||||||
|
dir_ = (base_dir.empty() ? default_events_dir() : base_dir) + "/" + safe;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,100 @@
|
|||||||
|
// 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.
|
||||||
|
/// `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 = {});
|
||||||
|
|
||||||
|
/// 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:
|
||||||
|
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
|
||||||
+405
-35
@@ -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,32 +194,39 @@ 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.
|
||||||
const std::vector<f32>& st = snap->stamps;
|
const std::vector<f64>& st = snap->stamps;
|
||||||
const auto lo = std::lower_bound(st.begin(), st.end(), static_cast<f32>(view.min));
|
const auto lo = std::lower_bound(st.begin(), st.end(), view.min);
|
||||||
const auto hi = std::upper_bound(st.begin(), st.end(), static_cast<f32>(view.max));
|
const auto hi = std::upper_bound(st.begin(), st.end(), view.max);
|
||||||
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] = static_cast<f64>(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),
|
||||||
src.begin() + static_cast<std::ptrdiff_t>(first + n))});
|
std::vector<f32>(src.begin() + static_cast<std::ptrdiff_t>(first),
|
||||||
|
src.begin() + static_cast<std::ptrdiff_t>(first + n))});
|
||||||
}
|
}
|
||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
@@ -141,30 +236,53 @@ 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;
|
||||||
|
|
||||||
BufferStore& store = model.live();
|
BufferStore& store = model.live();
|
||||||
std::vector<f32> stamps(static_cast<std::size_t>(n));
|
std::vector<f64> stamps(static_cast<std::size_t>(n));
|
||||||
store.time().copy_from(first, n, stamps.data());
|
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] = static_cast<f64>(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
|
||||||
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user