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

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

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

|
||||||
|
|
||||||
|
*The compact node editor: each node is a box, its inputs the squares on the left edge and its output
|
||||||
|
the square on the right. The orange dot marks the node that carries the channel's value.*
|
||||||
|
|
||||||
|
A math channel is derived from the others and is then an ordinary channel: enable it and it is
|
||||||
|
acquired, plotted, measured and exported like a streamed signal, and another math channel can use it
|
||||||
|
as a source.
|
||||||
|
|
||||||
|
Operations: `channel` (a source), `gain + offset`, `abs`, `sqrt`, `clamp`, `low pass`, `high pass`,
|
||||||
|
`band pass` (biquads, corner in Hz against the stored rate), `sum`, `difference`, `product`, `ratio`.
|
||||||
|
|
||||||
|
- **Click** a node to select it; its parameters appear in the form beside the canvas.
|
||||||
|
- **Click an output square, then an input square**, to wire them. An input is only offered the nodes
|
||||||
|
above it — the graph is a list walked in order, so a cycle cannot be drawn.
|
||||||
|
- **Drag** a box to move it. **add node**, **delete node** and **set as output** are below the form.
|
||||||
|
- The **output is the last node** of a graph you build top to bottom, and the orange dot says which;
|
||||||
|
*set as output* changes it. A graph whose output is the source is valid and just passes the input
|
||||||
|
through — that is the usual reason an operation "does nothing".
|
||||||
|
- **Save** refuses an incomplete graph and says why: an unconnected input, no output, or a source that
|
||||||
|
is not a channel the scope is acquiring.
|
||||||
|
|
||||||
|
The samples are produced at ingest, once per stored slot, so the filters carry real history — a low
|
||||||
|
pass is the filter you asked for rather than a window recomputed every frame.
|
||||||
|
|
||||||
|
## 10. Sessions
|
||||||
|
|
||||||
|
Everything above is saved per source in `~/.config/udpscope/<host>_<port>.toml`: the window, the rows
|
||||||
|
and which signal each slot holds, the calibration, **which signals were enabled**, **the math channels
|
||||||
|
with their graphs** and **the measurements**. Reopening the same source brings the scope back as you
|
||||||
|
left it.
|
||||||
|
|
||||||
|
The file is plain TOML and safe to edit — a malformed line is skipped rather than refusing the whole
|
||||||
|
file. Deleting it starts from the defaults.
|
||||||
|
|
||||||
|
## 11. Export
|
||||||
|
|
||||||
|
**Export visible range…** writes the plotted signals over the visible x range at the *stored* rate to
|
||||||
|
`.npy` or `.csv`, picked by the file name's extension. The table is collected when you click, not when
|
||||||
|
the chooser closes, so a live view cannot scroll under the dialog. The `.npy` is a 2-D array: one row
|
||||||
|
per sample, `time`, `stream_time`, then one column per signal.
|
||||||
|
|
||||||
|
## 12. Keyboard
|
||||||
|
|
||||||
|
| key | action |
|
||||||
|
|---|---|
|
||||||
|
| `space` | pause / resume |
|
||||||
|
| `a` | arm / disarm the trigger |
|
||||||
|
| `t` | the trigger panel |
|
||||||
|
| `l` | the layout editor |
|
||||||
|
| `g` | go live |
|
||||||
|
| `f` | fit to data |
|
||||||
|
| `d` | add the first free signal to the focused row |
|
||||||
|
| `c` | cursors on/off |
|
||||||
|
| `m` | measurements pane |
|
||||||
|
| `e` | export |
|
||||||
|
| `?` | shortcuts |
|
||||||
|
|
||||||
|
`Ctrl+E` exports, `Ctrl+Q` quits, `Ctrl+plus`/`Ctrl+minus` zoom x and `Alt+plus`/`Alt+minus` zoom y.
|
||||||
|
A keystroke is ignored while a text field has focus, so typing in the window control never arms the
|
||||||
|
trigger.
|
||||||
|
|
||||||
|
## 13. Troubleshooting
|
||||||
|
|
||||||
|
| symptom | cause |
|
||||||
|
|---|---|
|
||||||
|
| `connected, but no data is arriving` | another client holds a unicast source — use `--multicast` |
|
||||||
|
| the plot is empty, the status reads `no data` | nothing is enabled: enable a signal in the panel |
|
||||||
|
| the trace is blank while the trigger is armed | `Waiting` — armed and nothing latched yet |
|
||||||
|
| the axis has a margin at the right | a capture is filling: the post side arrives as it is acquired |
|
||||||
|
| a math channel equals its input | its output node is the source — see §9 |
|
||||||
|
| a very wide window shows a low rate | the ingest's anti-alias filter, not a fault |
|
||||||
|
| a level reads `--` | the measurement is invalid in this window (e.g. a period with one edge) |
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 168 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,252 @@
|
|||||||
|
# App icon, Adwaita About dialog, and desktop entry
|
||||||
|
|
||||||
|
Date: 2026-10-04
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Three related pieces of desktop integration udpscope currently lacks:
|
||||||
|
|
||||||
|
1. An application icon drawn to the GNOME HIG, in both full colour and symbolic form.
|
||||||
|
2. An About dialog that shows it, built on `AdwAboutDialog` rather than `GtkAboutDialog`.
|
||||||
|
3. A `.desktop` entry, so the app can be launched from the shell rather than only from a terminal.
|
||||||
|
|
||||||
|
## Decisions taken before design
|
||||||
|
|
||||||
|
**The application ID stays `org.udpscope.app`.** GNOME convention is that the application ID, the
|
||||||
|
icon name and the desktop file's basename are one string, so that string is `org.udpscope.app`
|
||||||
|
throughout: the icon is named `org.udpscope.app`, the desktop file is
|
||||||
|
`org.udpscope.app.desktop`, and `gtk_application_new()` is left exactly as it is. The alternative
|
||||||
|
(moving to `it.ferrarinas.Udpscope`, the reverse-DNS of the project's actual forge domain) was
|
||||||
|
offered and declined. Nothing else in the tree depends on the ID — the config directory is
|
||||||
|
`~/.config/udpscope` and the app is `G_APPLICATION_NON_UNIQUE` — so this choice costs nothing
|
||||||
|
beyond the naming itself.
|
||||||
|
|
||||||
|
**The icon is the instrument face**, not a bare waveform and not a colour version of the existing
|
||||||
|
trigger icon. A dark panel carrying one bold trace is the clearest available metaphor, it is the
|
||||||
|
kind of physical object the HIG's top-and-front depth language was written for, and its silhouette
|
||||||
|
survives being scaled down better than either alternative.
|
||||||
|
|
||||||
|
**The desktop entry is installed by an opt-in CMake target**, not by `install()` rules and not by
|
||||||
|
documented `cp` commands. The project deliberately has no install step — that is the stated reason
|
||||||
|
its icons are a GResource — and a target that does nothing until asked preserves that while still
|
||||||
|
producing a working entry. `install()` rules were rejected because they would create a second copy
|
||||||
|
of the app that can diverge from the build tree; manual `cp` was rejected because `Exec` would need
|
||||||
|
the binary on `PATH`, which it is not.
|
||||||
|
|
||||||
|
## The HIG constraints this is built to
|
||||||
|
|
||||||
|
Taken from <https://developer.gnome.org/hig/guidelines/app-icons.html>:
|
||||||
|
|
||||||
|
- Nominal canvas **128×128**, which the artwork should *not* fill.
|
||||||
|
- A **2px grid** for all detail.
|
||||||
|
- Depth comes from combining the top and front of an object: a front profile along the bottom,
|
||||||
|
**no taller than 2 detail units (4 nominal px)**.
|
||||||
|
- Flat colour on straight surfaces; **gradients reserved for curved surfaces**.
|
||||||
|
- Shadows **avoided** where possible.
|
||||||
|
- The standard GNOME palette.
|
||||||
|
- A **symbolic version is required**, reusing the same metaphor, following the UI-icon (single
|
||||||
|
colour) rules.
|
||||||
|
- The icon must still read at **64px** and **32px**.
|
||||||
|
|
||||||
|
## Component 1 — the artwork
|
||||||
|
|
||||||
|
Two new source files, both added to the existing `src/icons/udpscope.gresource.xml` under the
|
||||||
|
`scalable/apps/` tree (the icons already there are `scalable/actions/`).
|
||||||
|
|
||||||
|
### `src/icons/scalable/apps/org.udpscope.app.svg`
|
||||||
|
|
||||||
|
Full colour, 128×128, every coordinate on the 2px grid:
|
||||||
|
|
||||||
|
| element | geometry | palette colour |
|
||||||
|
|---|---|---|
|
||||||
|
| chassis | rounded rect (12, 22)–(116, 106), radius 8 | Dark 3 `#3d3846` |
|
||||||
|
| front profile | the bottom band of that same silhouette, y 102–106 | Dark 2 `#5e5c64` |
|
||||||
|
| screen | rounded rect (20, 30)–(108, 94), radius 4 | Dark 4 `#241f31` |
|
||||||
|
| trace | one sine cycle, centred y = 62, amplitude 18, over x 24–104, 6 px thick | Green 1 `#8ff0a4` |
|
||||||
|
|
||||||
|
The chassis is **one** rounded rect and the front profile is the bottom 4px *of that silhouette*,
|
||||||
|
drawn as a path that follows its bottom corners — not a separate full-width rectangle underneath it.
|
||||||
|
A plain rect from (12, 102) to (116, 106) would protrude past the radius-8 corners, which at 128px
|
||||||
|
reads as a misdrawn bump and at 32px as a dirty edge.
|
||||||
|
|
||||||
|
That band is exactly the HIG's 4px maximum and is the only depth treatment: no gradients (every
|
||||||
|
surface here is straight, so flat colour is what the HIG asks for) and no shadows.
|
||||||
|
|
||||||
|
The artwork occupies x 12–116 and y 22–106, leaving 12px side margins and 22px top and bottom, so it
|
||||||
|
does not fill the canvas. Every coordinate above is even.
|
||||||
|
|
||||||
|
Two deliberate omissions. There is **no graticule** — the grid lines a real scope face carries
|
||||||
|
cannot survive 32px, and the trace already carries the metaphor without them. And the trace is
|
||||||
|
**one** cycle, a single trough and a single crest, rather than several: at 32px the screen is about
|
||||||
|
22×16 px, and anything busier reads as a smudge.
|
||||||
|
|
||||||
|
### `src/icons/scalable/apps/org.udpscope.app-symbolic.svg`
|
||||||
|
|
||||||
|
16×16, the same metaphor reduced to a screen frame with a wave inside it. Unlike the colour icon
|
||||||
|
this is **filled paths only, with an `evenodd` hole for the screen's interior**, because GTK's
|
||||||
|
symbolic recolour only reaches `fill` on `path`, `rect` and `circle` — a stroke keeps the colour
|
||||||
|
written in the file and stays dark on a dark theme. Whole-pixel coordinates throughout.
|
||||||
|
|
||||||
|
**This asymmetry is the trap worth documenting:** CLAUDE.md's existing icon-authoring rules say
|
||||||
|
"everything is a filled `path`" without qualifying it, because every icon in the tree so far has
|
||||||
|
been symbolic. A 128px colour icon is never recoloured, so it may stroke freely — and stroking is
|
||||||
|
the sane way to draw a smooth sine. The rule becomes "fills only in a symbolic icon".
|
||||||
|
|
||||||
|
## Component 2 — the About dialog
|
||||||
|
|
||||||
|
`adw_about_dialog_new()` returns an `AdwDialog*`. An `AdwDialog` is **not** a `GtkWindow`, which is
|
||||||
|
the trap CLAUDE.md already documents for the shortcuts overview, so in `src/help_window.cpp`:
|
||||||
|
|
||||||
|
- `g_about` changes from `GtkWidget*` to `AdwDialog*`.
|
||||||
|
- It is cleared on `::closed`, not `::close-request` (`AdwDialog` has no such signal).
|
||||||
|
- It closes with `adw_dialog_close()`, not `gtk_window_close()`.
|
||||||
|
- It is presented with `adw_dialog_present()`.
|
||||||
|
- Both `AdwDialog`s can share one `::closed` handler keyed by a pointer-to-pointer, leaving
|
||||||
|
`on_window_close` to the help window alone.
|
||||||
|
|
||||||
|
`close_help_windows()` therefore becomes one `GtkWindow` (help) plus two `AdwDialog`s (About,
|
||||||
|
shortcuts), and both its doc comment and the CLAUDE.md invariant that says "two of the three are
|
||||||
|
`GtkWindow`s" have to be revised to "one of the three".
|
||||||
|
|
||||||
|
Field mapping:
|
||||||
|
|
||||||
|
| `GtkAboutDialog` | `AdwAboutDialog` |
|
||||||
|
|---|---|
|
||||||
|
| `program_name` | `application_name` |
|
||||||
|
| `version` | `version` |
|
||||||
|
| `comments` | `comments` |
|
||||||
|
| `license_type` | `license_type` |
|
||||||
|
| `system_information` | `debug_info` |
|
||||||
|
| — | `application_icon` = `"org.udpscope.app"` |
|
||||||
|
| — | `developer_name`, `website` |
|
||||||
|
|
||||||
|
Two gains are taken while mapping. `debug_info` renders as a Troubleshooting page with a copy
|
||||||
|
button, where `system_information` was inert text. And the string behind it is built as a
|
||||||
|
`std::string` rather than the `char details[1024]` a previous review flagged for silent truncation
|
||||||
|
on a long settings path — removing the ceiling rather than widening it, in code already being
|
||||||
|
rewritten.
|
||||||
|
|
||||||
|
Credits, release notes and an issue URL are deliberately **not** added; nothing asked for them.
|
||||||
|
|
||||||
|
One ordering detail: `gtk_icon_theme_add_resource_path()` currently runs at the top of
|
||||||
|
`build_toolbar()`. The main window's own icon is now set from a name in the same resource, so the
|
||||||
|
registration moves up into `run_ui()`, before both `gtk_window_set_icon_name()` and the
|
||||||
|
`build_toolbar()` call — otherwise whichever of the two runs first resolves its name to nothing.
|
||||||
|
|
||||||
|
## Component 3 — the desktop entry
|
||||||
|
|
||||||
|
### `data/org.udpscope.app.desktop.in`
|
||||||
|
|
||||||
|
```
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=udpscope
|
||||||
|
GenericName=Oscilloscope
|
||||||
|
Comment=An oscilloscope for UDPStreamer sources
|
||||||
|
Exec=@UDPSCOPE_EXEC@
|
||||||
|
Icon=org.udpscope.app
|
||||||
|
Terminal=false
|
||||||
|
Categories=Development;Science;Electronics;
|
||||||
|
Keywords=oscilloscope;UDP;signal;waveform;trigger;
|
||||||
|
StartupNotify=true
|
||||||
|
```
|
||||||
|
|
||||||
|
`@UDPSCOPE_EXEC@` is configured to the built binary's absolute path in the build tree. The
|
||||||
|
consequence to accept knowingly: **moving or deleting the build directory breaks the entry.** That
|
||||||
|
is the price of having no install step, and it is the right price here because the entry then always
|
||||||
|
launches exactly the binary that was last built.
|
||||||
|
|
||||||
|
No `StartupWMClass` is needed. GTK takes the WM class from the application ID, and the desktop
|
||||||
|
file's basename already matches it, which is what associates a running window with this entry.
|
||||||
|
|
||||||
|
### The `desktop-entry` target
|
||||||
|
|
||||||
|
A custom target driving `cmake/install_desktop_entry.cmake`, which writes three files under
|
||||||
|
`$XDG_DATA_HOME` (defaulting to `~/.local/share`):
|
||||||
|
|
||||||
|
- `applications/org.udpscope.app.desktop`
|
||||||
|
- `icons/hicolor/scalable/apps/org.udpscope.app.svg`
|
||||||
|
- `icons/hicolor/symbolic/apps/org.udpscope.app-symbolic.svg`
|
||||||
|
|
||||||
|
The two SVGs are copied from `src/icons/scalable/apps/` in the source tree, not extracted from the
|
||||||
|
GResource: the same two files serve both the in-binary resource and the installed icon theme, so
|
||||||
|
there is one copy to edit.
|
||||||
|
|
||||||
|
It then refreshes `update-desktop-database` and `gtk4-update-icon-cache` if either is on `PATH`, runs
|
||||||
|
`desktop-file-validate` if it is available, and prints every path it wrote.
|
||||||
|
|
||||||
|
`cmake --build build` is unaffected: the target runs only when named. No uninstall target is
|
||||||
|
provided — the three paths are printed, so undoing it is three `rm`s.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Two silent failures are possible, and they fail differently.
|
||||||
|
|
||||||
|
A GResource path that was never registered draws `image-missing` while every other line of the dump
|
||||||
|
passes; the existing `toolbar icons:` line already guards that class with
|
||||||
|
`gtk_icon_theme_has_icon()`. But a **malformed SVG is worse**: librsvg refuses the whole file,
|
||||||
|
`has_icon()` still returns true because the *name* resolves, and the icon draws nothing. `has_icon`
|
||||||
|
cannot see it — and these SVGs have already been bitten once by librsvg, which rejects a file for a
|
||||||
|
`--` inside an XML comment.
|
||||||
|
|
||||||
|
The two are therefore caught in two different places, because **no runtime GTK call can see the
|
||||||
|
malformed one.** This was measured rather than assumed, and three candidate checks were tried and
|
||||||
|
rejected:
|
||||||
|
|
||||||
|
- `gtk_icon_theme_lookup_icon()` never returns `NULL` and its paintable's intrinsic size is the
|
||||||
|
size that was *asked for*, so it reads `128x128` for a valid icon, a malformed one and a name
|
||||||
|
that does not exist at all. The same three cases snapshot to an identical `GSK_TEXTURE_NODE` of
|
||||||
|
the requested bounds, since GTK substitutes `image-missing`. There is nothing to compare.
|
||||||
|
- `GMarkup` accepts `<!-- a -- b -->` without complaint. It is a deliberately lenient subset
|
||||||
|
parser, so it cannot stand in for the strict one that refuses the file.
|
||||||
|
- `gdk_pixbuf_new_from_file()` on an SVG goes through glycin on this system, which sandboxes a
|
||||||
|
subprocess per load, emits fontconfig errors, and refuses this project's existing icons outright
|
||||||
|
for reasons of its own. Not a usable oracle.
|
||||||
|
|
||||||
|
So the malformed file is caught at **build time**, which is better than a probe anyway: every
|
||||||
|
`<file>` line in `src/icons/udpscope.gresource.xml` gains `preprocess="xml-stripblanks"`, which
|
||||||
|
makes `glib-compile-resources` pipe each SVG through `xmllint`. A double hyphen inside a comment
|
||||||
|
then **fails the build** with the file, line and column, instead of drawing nothing at runtime.
|
||||||
|
The four existing icons already pass it. The cost is that the `xmllint` binary becomes a build
|
||||||
|
dependency; it ships with libxml2, which GTK already requires.
|
||||||
|
|
||||||
|
The runtime line then asserts what a runtime check genuinely can:
|
||||||
|
|
||||||
|
```
|
||||||
|
app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ok` is `gtk_icon_theme_has_icon()` **and** the resource bytes being present under the registered
|
||||||
|
path, and the size is the one the file's root `<svg>` element declares, read out of those bytes.
|
||||||
|
**Both entries must read `ok` at those sizes.** That covers the unregistered-path case, a renamed
|
||||||
|
or mis-pathed file, and a canvas size that drifted off the HIG's 128 — and it is computed on the
|
||||||
|
first tick beside `toolbar icons:`, not in the dump, which runs after the window is destroyed.
|
||||||
|
|
||||||
|
Alongside it:
|
||||||
|
|
||||||
|
- The `[help]` probe gains `about icon=org.udpscope.app`, read back through
|
||||||
|
`adw_about_dialog_get_application_icon()`, so the dialog is asserted to carry the icon rather than
|
||||||
|
merely to open.
|
||||||
|
- That probe's existing `closed ok` becomes a stronger crash test, because About is now an
|
||||||
|
`AdwDialog` closed by a different call than the help window beside it. It must still produce no
|
||||||
|
`Gtk-CRITICAL` beyond the two already baselined.
|
||||||
|
- `desktop-file-validate` runs inside the `desktop-entry` target, so a malformed entry fails at the
|
||||||
|
moment it is asked for rather than being silently ignored by the shell later.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **CLAUDE.md**: the `app icons:` dump line as a documented assertion, and the `xml-stripblanks`
|
||||||
|
build-time check beside the five existing icon-authoring rules — the sixth rule is that the
|
||||||
|
malformed-SVG failure is a *build* error now and no runtime check can replace it; the windows invariant revised
|
||||||
|
to one `GtkWindow` and two `AdwDialog`s; the `desktop-entry` target and the build-path caveat; and
|
||||||
|
the icon-authoring rules amended so "filled paths only" is scoped to symbolic icons.
|
||||||
|
- **README.md**: the feature narrative — the icon's HIG constraints and the two omissions
|
||||||
|
(graticule, multiple cycles) with the 32px reasoning, the move to `AdwAboutDialog` and what it
|
||||||
|
gained, and how to get the desktop entry.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- An AppStream `metainfo.xml`. Nothing asked for it, and it is only required for store submission.
|
||||||
|
- Redrawing the four existing action icons.
|
||||||
|
- A nightly/devel icon variant.
|
||||||
|
- An uninstall target for the desktop entry.
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# About, shortcuts, help, and a two-tier status bar
|
||||||
|
|
||||||
|
udpscope has grown a lot of behaviour that is only written down in `CLAUDE.md` and `README.md`:
|
||||||
|
signals start disabled, a long window is decimated rather than refused, a measurement's rulers
|
||||||
|
are stepped because the pass is throttled, an export is the stored samples and not the LOD
|
||||||
|
buckets. None of it is reachable from the application. The plot's own keyboard and mouse
|
||||||
|
gestures are likewise undocumented in the app, and there is no way to tell which binary is
|
||||||
|
running. Meanwhile the status bar carries eleven fields on one line and is unreadable at a
|
||||||
|
glance.
|
||||||
|
|
||||||
|
This adds four things: an About dialog, a shortcut overview, a paged help window, and a
|
||||||
|
two-tier status bar. The first three are windows that explain the application and share a
|
||||||
|
content source; the fourth is independent, and is here because it is the same kind of work on
|
||||||
|
the same surface.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In: the three windows, the accelerator table behind two of them, app-level keyboard shortcuts,
|
||||||
|
one addition to `deps/plotter`'s `input::Controller` (axis-restricted keyboard zoom), and the
|
||||||
|
status bar's split into a glance line and a details popover.
|
||||||
|
|
||||||
|
Out: a translated help (the app is English-only throughout), a search box over the help, a
|
||||||
|
preferences dialog, and any change to what the status line *measures* — the fields move, none
|
||||||
|
is added or deleted.
|
||||||
|
|
||||||
|
## The About dialog
|
||||||
|
|
||||||
|
Three facts, as asked: version, build timestamp, and the path of the persistent settings.
|
||||||
|
|
||||||
|
**Version** is hand-maintained, `project(udpscope VERSION 0.1.0 LANGUAGES C CXX)`. Not
|
||||||
|
`git describe`: the repository has no tags, so `describe` degrades to a bare hash and presents
|
||||||
|
it as a version number.
|
||||||
|
|
||||||
|
**Build timestamp and provenance** come from a generated translation unit. `src/build_info.hpp`
|
||||||
|
declares three constants and a CMake custom command writes `build_info.cpp` defining them on
|
||||||
|
**every** build:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
namespace udpscope::build_info {
|
||||||
|
extern const char* const kVersion; ///< project(VERSION)
|
||||||
|
extern const char* const kCommit; ///< git describe --always --dirty, or "unknown"
|
||||||
|
extern const char* const kTimestamp; ///< "YYYY-MM-DD HH:MM UTC"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`__DATE__`/`__TIME__` are not used: they only move when that translation unit happens to
|
||||||
|
recompile, so a binary built today can honestly report last week. Regenerating unconditionally
|
||||||
|
costs one small compile and one relink per build, and it follows the pattern already in
|
||||||
|
`CMakeLists.txt:43-52`, where `udpscope_resources.c` is a generated source compiled into the
|
||||||
|
executable. The commit is included because it is the fact that actually answers "which binary
|
||||||
|
is this"; a timestamp alone does not. `kCommit` reads `unknown` when `git` is absent or the
|
||||||
|
tree is not a checkout — a build from a tarball must not fail for it.
|
||||||
|
|
||||||
|
**The settings path** is `layout_store::config_dir()`, the same function the session files use,
|
||||||
|
followed by `layout_path(host, port)` for the source this run is connected to — the file a user
|
||||||
|
actually wants to find. Both as selectable labels, so they can be copied.
|
||||||
|
|
||||||
|
The widget is a `GtkAboutDialog`. It is **not** deprecated in GTK 4.22 (it is
|
||||||
|
`GtkShortcutsWindow` that is), it gives the version field, a comments block and an MIT licence
|
||||||
|
section for free, and the two paths go in the comments block, which is the only part with room
|
||||||
|
for them.
|
||||||
|
|
||||||
|
## Keyboard shortcuts
|
||||||
|
|
||||||
|
### The plot's axis-restricted zoom, in `deps/plotter`
|
||||||
|
|
||||||
|
`input::Controller` has `+`/`-` zooming **both** ranges about the plot centre, and no way to
|
||||||
|
zoom one axis from the keyboard. It gains that, with the modifier convention the box-zoom drags
|
||||||
|
already use:
|
||||||
|
|
||||||
|
| keys | effect |
|
||||||
|
| --- | --- |
|
||||||
|
| `+` / `-` | zoom both ranges about the plot centre (unchanged) |
|
||||||
|
| `ctrl` + `+` / `-` | zoom the x axes only |
|
||||||
|
| `alt` + `+` / `-` | zoom the y axes only |
|
||||||
|
|
||||||
|
**Not `shift`, and this is the one deliberate deviation from the approved design.** On the
|
||||||
|
common layouts `+` *is* shift+`=`, and `key_from()` (`adapters/gtk4/gtk_input.hpp:258`) folds
|
||||||
|
both onto `kKeyZoomIn` precisely for that reason — so a bare zoom-in keystroke already arrives
|
||||||
|
with `Mod::Shift` set, and `shift + (+/-)` cannot be distinguished from `+`. Making Shift mean
|
||||||
|
x-only would turn every plain zoom into an x-only zoom. Ctrl and Alt are unambiguous for keys,
|
||||||
|
and they are already the controller's drag convention (`ctrl`+drag = x only, `alt`+drag = y
|
||||||
|
only), so the keyboard and the mouse-drag rows of the overview read the same. The **wheel**
|
||||||
|
keeps its own `shift`=x / `ctrl`=y: that is upstream behaviour with upstream tests, and the
|
||||||
|
overview shows both honestly rather than hiding one.
|
||||||
|
|
||||||
|
The change is smaller than it looks. `KeyEvent` already carries `mods`
|
||||||
|
(`include/plot/input/events.hpp:75`) and `PlotArea::on_key_pressed` already sets
|
||||||
|
`e.mods = mods_from(state)` (`adapters/gtk4/gtk_plot_area.cpp:862`), so no adapter change, no
|
||||||
|
new key constant, and no `key_from()` change: the whole change is the `if (zooms)` block of
|
||||||
|
`Controller::on_key`, which gains the same `has_mod(e.mods, Mod::Ctrl)` / `Mod::Alt` read the
|
||||||
|
drag branch makes at `src/input/controller.cpp:493-495` and skips the axis class the modifier
|
||||||
|
excluded. A comment on `kKeyZoomIn` records why Shift is not available here, so the next person
|
||||||
|
to reach for it finds the reason.
|
||||||
|
|
||||||
|
Two tests in the controller's existing suite — `ctrl_plus_zooms_x_only_and_leaves_y` and
|
||||||
|
`alt_plus_zooms_y_only_and_leaves_x` — then the submodule commit and the pin move.
|
||||||
|
|
||||||
|
### The application's keys
|
||||||
|
|
||||||
|
| keys | action |
|
||||||
|
| --- | --- |
|
||||||
|
| `Space` | pause / resume |
|
||||||
|
| `a` | arm / disarm the trigger |
|
||||||
|
| `t` | trigger panel |
|
||||||
|
| `c` | cursors pane |
|
||||||
|
| `m` | measurements pane |
|
||||||
|
| `g` | math pane |
|
||||||
|
| `l` | layout editor |
|
||||||
|
| `s` | side panel |
|
||||||
|
| `Ctrl+E` | export the visible range |
|
||||||
|
| `F1` | help |
|
||||||
|
| `Ctrl+slash` | shortcut overview |
|
||||||
|
| `Ctrl+Q` | quit |
|
||||||
|
|
||||||
|
Bare letters for the toggles, Ctrl for the things that touch files or windows. The plot's own
|
||||||
|
keys — `+`, `-`, `f`, the arrows, `Escape` — are not in this set and are not shadowed.
|
||||||
|
|
||||||
|
One `GtkEventControllerKey` on the toplevel, in the **bubble** phase. That is what makes a
|
||||||
|
focused widget win its own keys: the plot area returns `FALSE` for everything `key_from()` maps
|
||||||
|
to `0`, which is every letter in this table and `Space` too, so they bubble up to this
|
||||||
|
controller. A focused `GtkEntry` or `GtkSpinButton` consumes its printable keys first, so
|
||||||
|
typing into the window entry or a math field is unaffected. A focused `GtkButton` consumes
|
||||||
|
`Space` to activate itself, so `Space` pauses only when focus is not on a button — ordinary GTK
|
||||||
|
behaviour, and the right outcome. The handler additionally refuses every app key while the
|
||||||
|
focused widget is a `GtkEditable`: belt and braces, and the property a probe can assert, since
|
||||||
|
a probe calls the handler directly and so bypasses the bubbling the first guarantee rests on.
|
||||||
|
|
||||||
|
### The overview window
|
||||||
|
|
||||||
|
Hand-rolled, not `GtkShortcutsWindow`: that widget was deprecated in GTK 4.18, this project
|
||||||
|
builds `-Wall -Wextra -Wpedantic` clean, and 4.22 is what is installed.
|
||||||
|
|
||||||
|
A `GtkWindow` holding a `GtkScrolledWindow` over one two-column `GtkGrid` per section: the
|
||||||
|
accelerator on the left in a monospace face, the description on the right. Sections:
|
||||||
|
**Acquisition**, **Panes and editors**, **Files and windows**, **Plot keyboard**, **Plot
|
||||||
|
mouse**. The mouse section is the controller's gesture table in prose — `shift`/`ctrl`/`alt` +
|
||||||
|
drag for the three box zooms, drag and right-drag to pan, middle-drag on each label strip for
|
||||||
|
an axis zoom, the wheel with its two modifiers, horizontal wheel to pan x, double-click to
|
||||||
|
re-fit.
|
||||||
|
|
||||||
|
## The accelerator table
|
||||||
|
|
||||||
|
`src/shortcuts.hpp` holds **one** table, and both consumers read it:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
enum class AppAction { PauseToggle, TriggerArm, TriggerPanel, CursorsPane, MeasuresPane,
|
||||||
|
MathPane, LayoutEditor, SidePanel, Export, Help, Shortcuts, Quit };
|
||||||
|
|
||||||
|
struct Accel {
|
||||||
|
unsigned keyval; ///< GDK keyval
|
||||||
|
unsigned mods; ///< GdkModifierType mask, 0 for a bare key
|
||||||
|
AppAction action;
|
||||||
|
const char* accel_text; ///< what the overview prints
|
||||||
|
const char* description;
|
||||||
|
const char* section;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`ui.cpp`'s handler looks up `(keyval, mods)` in this table to get an `AppAction` and switches on
|
||||||
|
that; the overview renders `accel_text` and `description` from the same rows. A key that works
|
||||||
|
and a key that is documented therefore cannot drift apart — which is the failure this project
|
||||||
|
has hit before in another form, two comments in `build_toolbar()` disagreeing with each other
|
||||||
|
and one of them with reality.
|
||||||
|
|
||||||
|
The mouse gestures are a second, display-only table in the same file — a gesture text and a
|
||||||
|
description, with no keyval, since nothing dispatches them — because they are rendered by the
|
||||||
|
same window and belong beside the keys they are compared against.
|
||||||
|
|
||||||
|
## The help window
|
||||||
|
|
||||||
|
A `GtkWindow` split in two: a `GtkListBox` of page titles on the left, a `GtkScrolledWindow`
|
||||||
|
holding one wrapping `GtkLabel` on the right. Selecting a row swaps the label's markup. Opened
|
||||||
|
by `F1` and from the overflow menu; **non-modal**, so it can sit beside the scope while a user
|
||||||
|
follows it.
|
||||||
|
|
||||||
|
Each page is a file of **Pango markup** in a GResource (`src/help/*.txt`, bundled by
|
||||||
|
`src/help/udpscope-help.gresource.xml`, compiled by the same `glib-compile-resources` custom
|
||||||
|
command pattern the icons use). Pango markup rather than Markdown because `GtkLabel` renders it
|
||||||
|
natively — bold, italic and monospace spans with no parser to write — and a malformed span is
|
||||||
|
caught by `pango_parse_markup()` at load time rather than drawn wrong. A GResource rather than
|
||||||
|
files beside the binary for the reason already recorded for the icons: udpscope is run straight
|
||||||
|
out of the build tree and has no install step, so a search path would have to be located at
|
||||||
|
runtime and the failure mode of not locating it is silent.
|
||||||
|
|
||||||
|
A page's title, resource path and section header are one entry in a static table in
|
||||||
|
`help_window.cpp`, so the order on screen is explicit in code rather than implied by file names.
|
||||||
|
The section headers are non-selectable rows in the `GtkListBox`.
|
||||||
|
|
||||||
|
### Page shape
|
||||||
|
|
||||||
|
Every page has the same four parts:
|
||||||
|
|
||||||
|
1. what the feature does,
|
||||||
|
2. how to reach it,
|
||||||
|
3. **Worth knowing** — the surprising behaviour, distilled from the invariants in `CLAUDE.md`
|
||||||
|
and `README.md`,
|
||||||
|
4. **Example** — one worked case with real numbers taken from the probes, not round ones.
|
||||||
|
|
||||||
|
The approved template, which every other page follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
Time window
|
||||||
|
───────────
|
||||||
|
|
||||||
|
The window is how much time the plot shows. Set it from the header bar's
|
||||||
|
window button: type a number, pick a unit, or step a decade with the arrows.
|
||||||
|
|
||||||
|
Every row shares one window. The trigger's event span follows it, and the
|
||||||
|
pre-trigger share is a share of *this* window.
|
||||||
|
|
||||||
|
Worth knowing
|
||||||
|
A window too long to store at the wire rate is decimated, not refused. The
|
||||||
|
shared clock takes a power-of-two stride; each group of samples is
|
||||||
|
*averaged*, never strided, so a tone above the stored Nyquist is attenuated
|
||||||
|
instead of aliasing to a false low frequency.
|
||||||
|
|
||||||
|
Moving the window across a decimation boundary rebuilds the buffer and drops
|
||||||
|
the history.
|
||||||
|
|
||||||
|
Example
|
||||||
|
1 MS/s, 200 MB of buffer. A 2 s window stores every sample (time x1). Widen
|
||||||
|
to 600 s and the stride becomes x16: 625 S/s stored, anti-aliased at
|
||||||
|
312.5 Hz.
|
||||||
|
```
|
||||||
|
|
||||||
|
### The pages
|
||||||
|
|
||||||
|
**Getting started**
|
||||||
|
|
||||||
|
1. **Overview** — what each part of the window is: toolbar, side panel, plot rows, status bar.
|
||||||
|
The map the other pages refer back to.
|
||||||
|
2. **Choosing a source** — the startup chooser, why the list shows only sessions that name a
|
||||||
|
signal, what `×` deletes, the New host… form, and that a stored multicast session reconnects
|
||||||
|
to its group and interface.
|
||||||
|
3. **Signals and calibration** — signals start **disabled** and enabling is what starts
|
||||||
|
acquisition; a signal on no plot still gets a grey chip; calibration is applied at ingest, so
|
||||||
|
changing it rebuilds the buffer and drops history.
|
||||||
|
4. **Rows, layout and drag & drop** — drag a signal's chip from the side panel onto a row to
|
||||||
|
plot it; four slots per row; a signal's colour follows the *signal*, not the slot, so the same
|
||||||
|
signal on two rows is one colour; the layout editor's `+`/`×`, and that inserting a row
|
||||||
|
carries the existing rows' contents down with them rather than renumbering under the user.
|
||||||
|
|
||||||
|
**Viewing**
|
||||||
|
|
||||||
|
5. **Time window** — the template above.
|
||||||
|
6. **Navigating the plot** — the keyboard and mouse gestures in prose, plus what a *pinned* view
|
||||||
|
is: while an event is held, a capture is filling or the view is paused, a zoom in works but
|
||||||
|
the view cannot leave the data.
|
||||||
|
7. **Pause and play** — pausing freezes the *display*, not the producer: the ring keeps filling,
|
||||||
|
the view binds a snapshot, and the axis is pinned to the snapshot's own stamps, so a window
|
||||||
|
reaching back past the oldest retained sample shows a shorter span rather than an empty
|
||||||
|
margin.
|
||||||
|
8. **Trigger** — arm/disarm, edge and level, Normal vs Single, the pre-trigger share as a share
|
||||||
|
of *this* window, the four display states (live, capturing, held, waiting), that the trigger
|
||||||
|
sees the **stored** filtered signal, and that disarming mid-capture publishes what the capture
|
||||||
|
got to.
|
||||||
|
|
||||||
|
**Analysis**
|
||||||
|
|
||||||
|
9. **Cursors** — the shared vertical pair versus the per-row rulers, the four modes, what `mix`
|
||||||
|
reads, and that a marker past the newest sample reads `--`.
|
||||||
|
10. **Measurements** — the types, the threshold share, the on-plot marks, that a pass is
|
||||||
|
throttled to a cadence so a ruler is stepped, and that an invalid pass keeps its level marks
|
||||||
|
and drops its time marks.
|
||||||
|
11. **Math channels** — a graph evaluated as an ordered list, so a cycle is impossible by
|
||||||
|
construction; a derived channel *is* a channel; the filters run at the **stored** rate, so a
|
||||||
|
cutoff moves when the window changes the decimation; the output defaults to the last node.
|
||||||
|
|
||||||
|
**Output and state**
|
||||||
|
|
||||||
|
12. **Saving and export** — `.npy` and `.csv`, the visible range at the **stored** rate and never
|
||||||
|
the LOD buckets, the `time`/`stream_time` columns, and that the table is collected at click
|
||||||
|
time rather than when the file chooser returns.
|
||||||
|
13. **Sessions and settings** — where the files live, what a session persists (window, rows,
|
||||||
|
calibration, enabled signals, math graphs, measurements), when it is written, and why a
|
||||||
|
session whose streamer was down during a run is not overwritten.
|
||||||
|
14. **Status bar and diagnostics** — the glance line, what is behind ⓘ, and what `gaps`,
|
||||||
|
`reconnects` and the starvation notice actually mean.
|
||||||
|
|
||||||
|
## The status bar
|
||||||
|
|
||||||
|
### The glance line
|
||||||
|
|
||||||
|
One row on the bar, four fields and no more:
|
||||||
|
|
||||||
|
```
|
||||||
|
● live · 10.0 kS/s · 2 s window ⓘ
|
||||||
|
```
|
||||||
|
|
||||||
|
The badge is unchanged — one glyph in three colours. The state field becomes one short word:
|
||||||
|
`live`, `paused`, `armed`, `capturing`, `event held`, `disarmed`. The long prose it carries
|
||||||
|
today (`trigger: EVENT HELD (Arm to re-arm, or 'Go live' for streaming)`) moves into the
|
||||||
|
popover. Trigger mode adds exactly one field, `· event 4.2 s ago`.
|
||||||
|
|
||||||
|
Each field's width is bounded, for the reason `CLAUDE.md` already gives for `rate` collapsing
|
||||||
|
to one figure and for `format_elapsed()` stepping units: the status row sits directly above the
|
||||||
|
plot, so a field whose width tracks the stream relayouts the plot. That is why the state field
|
||||||
|
is a word from a fixed set rather than a sentence.
|
||||||
|
|
||||||
|
The transient notice (`App::notice`) stays on the bar, prefixed to the glance line as it is
|
||||||
|
today: it is the one piece of text that must be seen without a click.
|
||||||
|
|
||||||
|
### The details popover
|
||||||
|
|
||||||
|
Behind an ⓘ `GtkMenuButton` at the end of the row, a `GtkPopover` over a `GtkGrid` of
|
||||||
|
label/value pairs:
|
||||||
|
|
||||||
|
```
|
||||||
|
Stream connected, configured
|
||||||
|
packets 717 · frames 716 · gaps 0
|
||||||
|
3 signals · 3 channels
|
||||||
|
Buffer 200.0 MB · 1677 s span · time x1
|
||||||
|
stored 10.0 kS/s (wire 10.0 kS/s)
|
||||||
|
Plot 1 3 traces · 195000 samples
|
||||||
|
Reconnects 0
|
||||||
|
```
|
||||||
|
|
||||||
|
It is rewritten **only while it is visible**. A popover nobody opened costs nothing per frame,
|
||||||
|
and a visible one cannot relayout the plot because a popover is its own surface — which is the
|
||||||
|
property that makes the full-width detail safe to show at all.
|
||||||
|
|
||||||
|
### One composition, two consumers
|
||||||
|
|
||||||
|
`src/status_text.hpp` holds a `StatusSnapshot` of the fields the tick already computes, and two
|
||||||
|
formatters:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
std::string format_glance(const StatusSnapshot&); ///< the bar
|
||||||
|
std::string format_detail(const StatusSnapshot&); ///< the popover, and the exit dump
|
||||||
|
```
|
||||||
|
|
||||||
|
`CLAUDE.md` asserts that `rate` and `last event … ago` are checked in the exit dump's
|
||||||
|
`dbg_mode_live`/`dbg_mode_triggered`, which today are `gtk_label_get_text(app->status_label)`.
|
||||||
|
The tick therefore still assembles the complete detail text every frame, into
|
||||||
|
`App::status_detail`; the dump reads **that** instead of the label, and the popover formats
|
||||||
|
from the same snapshot. The existing dump assertions keep working unchanged, and the detail is
|
||||||
|
not a second expression of fields the glance line also computes.
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
| --- | --- |
|
||||||
|
| `src/build_info.hpp` | new: declares the three constants |
|
||||||
|
| *generated* `build_info.cpp` | new: defines them, rewritten every build |
|
||||||
|
| `src/shortcuts.hpp/.cpp` | new: the accelerator table and the mouse-gesture table |
|
||||||
|
| `src/help_window.hpp/.cpp` | new: `show_help_window()`, `show_shortcuts_window()`, `show_about_dialog()` |
|
||||||
|
| `src/help/*.txt`, `src/help/udpscope-help.gresource.xml` | new: the fourteen pages |
|
||||||
|
| `src/status_text.hpp/.cpp` | new: `StatusSnapshot`, `format_glance()`, `format_detail()` |
|
||||||
|
| `src/ui.cpp` | the key controller, three menu items, the ⓘ button and popover, the glance label, `App::status_detail`, the new probes |
|
||||||
|
| `CMakeLists.txt` | `VERSION`, the build-info command, the help resource, the new sources |
|
||||||
|
| `deps/plotter` | `Controller::on_key`'s zoom branch, two tests, the pin move |
|
||||||
|
| `CLAUDE.md`, `README.md` | the documentation this project requires for both |
|
||||||
|
|
||||||
|
The three windows share one translation unit because they share one purpose, the GResource
|
||||||
|
access and the accelerator table. `src/ui.cpp` is already ~7400 lines and the largest file in
|
||||||
|
the project, so none of them goes in it.
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Case | Behaviour |
|
||||||
|
| --- | --- |
|
||||||
|
| A help page's resource is missing | the page's label reads a one-line "this page is missing" and the probe's `pages`/`parsed` counts disagree; nothing else is affected |
|
||||||
|
| A help page's markup is malformed | `pango_parse_markup()` fails, the page falls back to the same line, and `parsed` < `pages` |
|
||||||
|
| `git` absent or not a checkout | `kCommit` is `unknown`; the build succeeds |
|
||||||
|
| An app key pressed while a `GtkEditable` has focus | refused, the widget keeps it |
|
||||||
|
| A window already open | presented, not rebuilt — each of the three keeps one static instance pointer, and closes with `gtk_window_close()`, never `gtk_window_destroy()`, so the handler that clears the pointer runs (the trap the math dialog hit) |
|
||||||
|
| The ⓘ popover opened with no CONFIG yet | the detail fields read what they read today in that state; no special case |
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Probes on stderr in the style of the existing ones, since this project's whole regression net is
|
||||||
|
those probes and `CLAUDE.md` requires extending them rather than adding a harness.
|
||||||
|
|
||||||
|
- **`UDPSCOPE_PROBE_HELP_MS`** — open all three windows, report, close them. Equal parts crash
|
||||||
|
test and content check, the way `UDPSCOPE_PROBE_MATH_MS` is:
|
||||||
|
|
||||||
|
```
|
||||||
|
[help] pages=14 parsed=14 shortest='Overview' 412 B; shortcuts: app=12 mouse=9;
|
||||||
|
about version=0.1.0 commit=53815ad built=2026-10-04 14:22 UTC
|
||||||
|
settings=/home/martino/.config/udpscope; closed ok
|
||||||
|
```
|
||||||
|
|
||||||
|
**`parsed` must equal `pages`**, which is what catches a malformed span as a number instead of
|
||||||
|
as mangled text on screen; **no page may be empty**, which catches a resource listed in the
|
||||||
|
`.xml` and never written; `settings=` must be a real path. `closed ok` with no critical is the
|
||||||
|
lifecycle assertion.
|
||||||
|
|
||||||
|
- **`UDPSCOPE_PROBE_KEYS_MS`** — fire every row of the accelerator table at the handler directly
|
||||||
|
(the way the other probes emit `::clicked`) and report the state each one moved:
|
||||||
|
|
||||||
|
```
|
||||||
|
[keys] a: armed 0->1 | space: paused 0->1 | c: cursors 0->1 | ... |
|
||||||
|
editable focused: 'm' refused=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Every row must move exactly its own state and nothing else, and the last field must read
|
||||||
|
`refused=1`.
|
||||||
|
|
||||||
|
- **`UDPSCOPE_PROBE_ZOOMKEYS_MS`** — the new plotter keys through the app, inside a **pinned**
|
||||||
|
view, so run it with the pause probe, which makes a stable pin
|
||||||
|
(`UDPSCOPE_PROBE_PAUSE_MS=1500 UDPSCOPE_PROBE_ZOOMKEYS_MS=2200`):
|
||||||
|
|
||||||
|
```
|
||||||
|
[zoomkeys] ctrl+plus x=[..]->[..] y_held=1 | alt+plus y=[..]->[..] x_held=1
|
||||||
|
```
|
||||||
|
|
||||||
|
Both `_held` must be 1 and both zooms must still happen: "a pinned view is navigable within
|
||||||
|
its data" is the invariant, and a modified zoom must not become its exception.
|
||||||
|
|
||||||
|
- **The status bar**, in the exit dump:
|
||||||
|
|
||||||
|
```
|
||||||
|
status bar: glance=[● live · 10.0 kS/s · 2 s window] max_glance_chars=38
|
||||||
|
detail_fields=11 popover_rebuilds=0
|
||||||
|
```
|
||||||
|
|
||||||
|
**`max_glance_chars` must stay under a small constant** over the whole run — that is "layout
|
||||||
|
must not depend on content" read from the one label still on the bar. **`popover_rebuilds`
|
||||||
|
must be 0** on a run that never opened it.
|
||||||
|
|
||||||
|
- **The plotter's own suite** — `xvfb-run -a deps/plotter/build/debug/tests/plot_tests`, `0
|
||||||
|
failed`, with the two new controller tests.
|
||||||
|
|
||||||
|
- **A full existing run must be unchanged.** The status refactor touches the line every other
|
||||||
|
probe's dump is read beside, so one `UDPSCOPE_EXIT_AFTER_MS` run with
|
||||||
|
`UDPSCOPE_ENABLE_ALL_MS` must still report `gaps=0`, `x-desync 0`, `0 collapsed, 0 staircase`
|
||||||
|
and every toolbar icon `(ok)`.
|
||||||
|
|
||||||
|
`CLAUDE.md` gains these probes, plus invariants for the shared accelerator table, the
|
||||||
|
Shift-is-unexpressible reason behind ctrl/alt, and the one-composition-two-consumers status
|
||||||
|
rule. `README.md` gains the four features and records the deviation from the approved
|
||||||
|
`shift + (+/-)`.
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
|
||||||
|
# The math node editor: wires you can draw and delete, and a canvas you can navigate
|
||||||
|
|
||||||
|
The math channel editor works as a *form* and fails as a *graph editor*. Three things were
|
||||||
|
reported — edges cannot be added, edges cannot be deleted, and the canvas cannot be zoomed or
|
||||||
|
panned — and all three are true. The first is a defect with a specific cause, the second has no
|
||||||
|
code path at all, and the third is an absence. Beyond them the editor asks the user to obey a
|
||||||
|
rule the data structure has (a node may only read nodes above it in the list) rather than
|
||||||
|
maintaining that rule itself, and it reports an invalid graph in a modal alert at the end
|
||||||
|
instead of on the canvas as it is being built.
|
||||||
|
|
||||||
|
This redesigns the canvas interaction. Nothing below changes `MathNode`, `MathChannel`, the
|
||||||
|
evaluator in `src/math_channel.cpp`, the session format, or the producer's path.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In: the canvas view transform, the port and wire hit tests, the gesture state machine
|
||||||
|
(node drag / wire drag / background pan / wheel zoom), edge creation and deletion, a
|
||||||
|
topological re-sort that lets a wire be drawn in either direction, an op picker on the add
|
||||||
|
button, a live validation line that replaces the modal alert, a tidy/auto-layout pass, the
|
||||||
|
`Cancel` button's close path, and the `[math-probe]` assertions for all of it.
|
||||||
|
|
||||||
|
Out: parameters drawn on the node boxes (explicitly declined — the form beside the canvas is
|
||||||
|
where a node's values live). No new ops. No change to `math_channel_valid()`'s rules, to the
|
||||||
|
evaluator, to `MathRuntime`, or to how a derived channel is acquired. No node *library* panel,
|
||||||
|
no copy/paste, no undo.
|
||||||
|
|
||||||
|
## Why edges cannot be added today
|
||||||
|
|
||||||
|
`math_canvas_pressed` (`src/ui.cpp:3455`) handles an output-port hit like this:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
const int port = math_port_at(d, x, y, which);
|
||||||
|
if (port == -2) {
|
||||||
|
const int node = math_node_at(d, x, y);
|
||||||
|
d->pending_out = (node >= 0 && d->pending_out == node) ? -1 : node;
|
||||||
|
...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`math_port_at` throws away *which* node's output was hit — it returns the sentinel `-2` — and
|
||||||
|
the handler recovers the node with `math_node_at()`, which matches only **inside the box**. The
|
||||||
|
output square is drawn at `x = n.x + kNodeW`, i.e. straddling the right edge: half of it is
|
||||||
|
outside. Its hit radius is `kPort` (±10) while it is drawn at `±kPort * 0.5` (±5), so the
|
||||||
|
clickable region extends 10 px past the edge and `math_node_at()` answers `-1` for every pixel
|
||||||
|
of it beyond the first. `pending_out` is then set to `-1` — a *silent disarm*. Clicking the
|
||||||
|
visible dot's outer half, which is most of what the eye aims at, arms nothing, and the input
|
||||||
|
squares never light up. That is the whole of "it is not possible to add edges": the mechanism
|
||||||
|
exists and the first click of the two-click sequence usually fails.
|
||||||
|
|
||||||
|
It is worth fixing the cause and not only the symptom, because the same re-derivation would
|
||||||
|
break again under the view transform below.
|
||||||
|
|
||||||
|
Edge *deletion* needs no diagnosis: no path in the file ever writes `-1` back into `in_a` or
|
||||||
|
`in_b` except `on_math_node_deleted`, which is deleting the whole node.
|
||||||
|
|
||||||
|
## The view transform
|
||||||
|
|
||||||
|
`MathDialog` gains three doubles:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
double view_scale = 1.0; ///< canvas pixels per graph unit
|
||||||
|
double view_x = 0.0; ///< canvas pixels: where graph (0,0) is drawn
|
||||||
|
double view_y = 0.0;
|
||||||
|
```
|
||||||
|
|
||||||
|
Graph coordinates are what `MathNode::x`/`y` already are and what the session file stores, so a
|
||||||
|
view change is never persisted — reopening the editor shows the graph at 1:1, which is the
|
||||||
|
right default for a window whose size the user did not choose either.
|
||||||
|
|
||||||
|
Two conversions, and **every** hit test goes through the second:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
// canvas <- graph: applied once to the cairo context, never per item.
|
||||||
|
cairo_translate(cr, d->view_x, d->view_y);
|
||||||
|
cairo_scale(cr, d->view_scale, d->view_scale);
|
||||||
|
|
||||||
|
// graph <- canvas: the inverse, for a pointer position.
|
||||||
|
void math_to_graph(const MathDialog* d, double cx, double cy, double& gx, double& gy) {
|
||||||
|
gx = (cx - d->view_x) / d->view_scale;
|
||||||
|
gy = (cy - d->view_y) / d->view_scale;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Applying the transform to the context rather than to each item is what keeps the drawing code
|
||||||
|
unchanged: `math_canvas_draw` keeps every coordinate it has today, and the line widths and font
|
||||||
|
sizes scale with the graph, which is what a zoom should do to a diagram.
|
||||||
|
|
||||||
|
**A hit tolerance is a screen distance, not a graph distance.** A port is aimed at with a
|
||||||
|
pointer, so its clickable radius must be the same number of *canvas* pixels at every zoom —
|
||||||
|
which in graph units is `kPort / view_scale`. The same for the wire tolerance below. Getting
|
||||||
|
this backwards makes the ports unhittable when zoomed out, which is exactly when a user is
|
||||||
|
looking for them.
|
||||||
|
|
||||||
|
**Scale is clamped to `[0.25, 4.0]`.** Below a quarter the op names are unreadable and the
|
||||||
|
boxes are smaller than the pointer tolerance; above four a single node fills the canvas. The
|
||||||
|
clamp lives in one place, a `math_set_scale()` helper, because three callers reach it (wheel,
|
||||||
|
Fit, reset).
|
||||||
|
|
||||||
|
### Wheel zoom about the pointer
|
||||||
|
|
||||||
|
A `GtkEventControllerScroll` with `GTK_EVENT_CONTROLLER_SCROLL_VERTICAL`. The anchor is the
|
||||||
|
graph point under the pointer, and it must not move:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
double gx = 0.0, gy = 0.0;
|
||||||
|
math_to_graph(d, px, py, gx, gy); // before
|
||||||
|
d->view_scale = clamp(d->view_scale * (dy < 0 ? 1.1 : 1.0 / 1.1), 0.25, 4.0);
|
||||||
|
d->view_x = px - gx * d->view_scale; // restore the anchor
|
||||||
|
d->view_y = py - gy * d->view_scale;
|
||||||
|
```
|
||||||
|
|
||||||
|
The pointer position comes from the controller's own `GtkEventController` current event rather
|
||||||
|
than from a tracked motion position: a scroll event carries one, and tracking motion to supply
|
||||||
|
it is a second source of truth that can be stale.
|
||||||
|
|
||||||
|
No modifier is involved. The canvas has no other use for the wheel, so a bare wheel zoom is
|
||||||
|
unambiguous here — unlike the plot, where the wheel's modifiers are already spoken for.
|
||||||
|
|
||||||
|
### Pan, Fit, and reset
|
||||||
|
|
||||||
|
Dragging the empty background pans: `view_x = pan_x0 + dx`, from a `pan_x0` recorded at
|
||||||
|
`drag-begin`, for the same total-offset reason the node drag already documents at
|
||||||
|
`src/ui.cpp:3507`.
|
||||||
|
|
||||||
|
A **Fit** button frames every node:
|
||||||
|
|
||||||
|
```
|
||||||
|
bbox over all math_node_rect() (empty graph -> identity view)
|
||||||
|
scale = clamp(min(w_canvas / (bbox.w + 2*pad), h_canvas / (bbox.h + 2*pad)), 0.25, 4.0)
|
||||||
|
view_* = centre the scaled bbox in the canvas
|
||||||
|
```
|
||||||
|
|
||||||
|
`Ctrl+0` resets to `scale = 1`, `view_x = view_y = 0`. Both are one call to a shared
|
||||||
|
`math_fit_view()` / `math_reset_view()` so the button and the accelerator cannot drift apart.
|
||||||
|
|
||||||
|
## The port and wire hit tests
|
||||||
|
|
||||||
|
`math_port_at` is replaced by one that **names the node it found**:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// The port under a *graph* position. Returns the node index, with `which` set to
|
||||||
|
/// `kPortOut` (-1) for its output or 0/1 for an input; `-1` when no port is under it.
|
||||||
|
int math_port_at(const MathDialog* d, double gx, double gy, double tol, int& which);
|
||||||
|
```
|
||||||
|
|
||||||
|
Two changes beyond the signature. It scans **backward**, topmost first, so it agrees with
|
||||||
|
`math_node_at()` about which of two overlapping nodes owns a shared pixel — a disagreement
|
||||||
|
between the two searches is how a click lands on one node and a drag on another. And the
|
||||||
|
caller never re-derives the owner, which is the defect above made structurally impossible.
|
||||||
|
|
||||||
|
The hit radius stays *larger* than the drawn square: ±`kPort` canvas pixels around the port's
|
||||||
|
centre, against a square drawn at ±`kPort * 0.5` — a 20 px target for a 10 px mark. That is
|
||||||
|
deliberate and, once the owner is no longer re-derived, harmless: a generous radius is the
|
||||||
|
difference between a diagram that can be wired and one that cannot. The asymmetry is written
|
||||||
|
down in a comment at the hit test, because it is what the old defect fed on.
|
||||||
|
|
||||||
|
A wire is picked by distance to the curve it is drawn as. The curve is the same cubic the
|
||||||
|
renderer uses (`src/ui.cpp:3401`), so the pick and the ink cannot disagree:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// The wire under a graph position, as (consumer node, which input), or {-1, 0}.
|
||||||
|
/// The bezier is sampled at 24 points and the nearest segment measured; a curve 40 units of
|
||||||
|
/// tread wide over a node pitch of 160 is smooth at that sampling, and the tolerance is 6
|
||||||
|
/// canvas pixels, so a miss by a pixel is not a wrong edge deleted.
|
||||||
|
MathWireRef math_wire_at(const MathDialog* d, double gx, double gy, double tol);
|
||||||
|
```
|
||||||
|
|
||||||
|
Sampling rather than solving: the exact distance to a cubic is a fifth-degree root-find, and 24
|
||||||
|
samples of a curve whose control points are 40 units out is well inside a 6 px tolerance. The
|
||||||
|
sample count is a named constant so a future wider tread can raise it.
|
||||||
|
|
||||||
|
**Order of precedence on a press**: port, then wire, then node body, then background. A port
|
||||||
|
sits on a node's edge and a wire ends on a port, so each later test must only see what the
|
||||||
|
earlier ones refused.
|
||||||
|
|
||||||
|
## The gesture state machine
|
||||||
|
|
||||||
|
One `GtkGestureDrag` serves four gestures, decided entirely by what the press landed on. A
|
||||||
|
`GtkGestureClick` is kept for the selection-only cases, because a click is a drag of zero
|
||||||
|
length and GTK delivers both; `drag-begin` records the mode and `pressed` only ever *selects*.
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
enum class MathDrag { None, Node, Wire, Pan };
|
||||||
|
MathDrag drag_mode = MathDrag::None;
|
||||||
|
int wire_from = -1; ///< Wire: the node whose output the live wire leaves
|
||||||
|
int wire_detach = -1; ///< Wire: the consumer whose input was detached to start it, or -1
|
||||||
|
int wire_detach_which = 0;
|
||||||
|
double wire_x = 0.0; ///< Wire: the pointer, in graph units, for the preview
|
||||||
|
double wire_y = 0.0;
|
||||||
|
double pan_x0 = 0.0;
|
||||||
|
double pan_y0 = 0.0;
|
||||||
|
MathWireRef selected_wire{-1, 0};
|
||||||
|
```
|
||||||
|
|
||||||
|
`drag-begin`, in precedence order:
|
||||||
|
|
||||||
|
- **An output port** → `Wire`, `wire_from = node`, `wire_detach = -1`.
|
||||||
|
- **A wired input port** → `Wire`, `wire_from = <the node it was wired from>`,
|
||||||
|
`wire_detach = node`, `wire_detach_which = which`, and the input is cleared *immediately*.
|
||||||
|
Dragging an existing wire off its input and dropping it elsewhere is one gesture, and
|
||||||
|
clearing on begin is what makes the preview show the truth: the wire the user is holding.
|
||||||
|
Dropping it on nothing therefore *detaches*, which is the agreed detach gesture, and needs
|
||||||
|
no separate case.
|
||||||
|
- **An unwired input port** → `None` (select the node; there is nothing to drag from an empty
|
||||||
|
input). Wiring is always output→input, so this is not a connect gesture.
|
||||||
|
- **A node body** → `Node`, exactly as today.
|
||||||
|
- **Anything else** → `Pan`.
|
||||||
|
|
||||||
|
`drag-update` moves the node, the live wire's endpoint, or the view, by mode.
|
||||||
|
|
||||||
|
`drag-end` resolves `Wire`:
|
||||||
|
|
||||||
|
- Released on an **input port** of a node that is not `wire_from` → `math_connect()`.
|
||||||
|
- Released on an **input port of `wire_from` itself** → refused, with "a node cannot read
|
||||||
|
itself" on the validation line. A self-edge is the one cycle a topological sort cannot
|
||||||
|
break, and refusing it by name reads better than "the graph has a cycle".
|
||||||
|
- Released anywhere else → nothing further. If the drag began by detaching, the detachment
|
||||||
|
stands.
|
||||||
|
|
||||||
|
`Escape` during a `Wire` drag cancels and **restores** a detached input: a cancel must undo the
|
||||||
|
clear-on-begin, or Escape would be a way to delete an edge by accident.
|
||||||
|
|
||||||
|
A `GtkEventControllerKey` on the canvas handles `Delete`/`BackSpace` (clear `selected_wire`),
|
||||||
|
`Escape` (cancel a drag, else clear the wire selection) and `Ctrl+0` (reset the view). It is
|
||||||
|
added to the canvas and the canvas is given `gtk_widget_set_focusable(TRUE)` plus focus on
|
||||||
|
press, because a drawing area is not focusable by default and a key controller on an unfocused
|
||||||
|
widget never fires. **The key controller must not swallow text**: the dialog's name and unit
|
||||||
|
entries are `GtkEditable`s, and the controller is on the *canvas*, so a key only reaches it
|
||||||
|
while the canvas has focus — the same reasoning as the app-level accelerators' bubble phase,
|
||||||
|
reached by a narrower route.
|
||||||
|
|
||||||
|
## The edge model
|
||||||
|
|
||||||
|
One function owns connection:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// Wire `from`'s output into `to`'s input `which`. Re-sorts the node list when the wire runs
|
||||||
|
/// backwards. Returns false and sets `why` when the wire would make a cycle.
|
||||||
|
bool math_connect(MathDialog* d, int from, int to, int which, std::string* why);
|
||||||
|
```
|
||||||
|
|
||||||
|
and deletion is a one-liner at the call site (`in_a`/`in_b` back to `-1`), because there is
|
||||||
|
nothing to validate in removing an edge: a disconnected input is a state the validation line
|
||||||
|
already names.
|
||||||
|
|
||||||
|
### Backwards wires re-sort the list
|
||||||
|
|
||||||
|
`MathChannel::nodes` is a list walked in order, and the evaluator's correctness rests on a node
|
||||||
|
reading only nodes before it. Today that rule is the *user's* problem: the input ports of nodes
|
||||||
|
above the armed output are not even drawn as armed, so a wire that would run backwards cannot
|
||||||
|
be drawn at all. In a two-column auto-placed graph that is a real obstruction — the node you
|
||||||
|
want to feed is frequently the one that happens to sit earlier in the list.
|
||||||
|
|
||||||
|
The rule becomes the editor's invariant instead. `math_connect()` on a backwards wire
|
||||||
|
(`from > to`) builds the new edge set, runs a topological sort, and commits only if it is
|
||||||
|
acyclic:
|
||||||
|
|
||||||
|
1. Trial edge set = every node's existing inputs, plus the new one.
|
||||||
|
2. Kahn's algorithm over it. Ties broken by **current list index**, so a sort that changes
|
||||||
|
nothing leaves the list alone and a sort that must move something moves as little as
|
||||||
|
possible. (A stable order matters: the node positions on the canvas do not change, so a
|
||||||
|
gratuitous renumber would silently re-point `output` and `selected` for no reason the user
|
||||||
|
can see.)
|
||||||
|
3. On a cycle: nothing is written, `why` is set, and the validation line shows it. The edge is
|
||||||
|
*not* created — this is the one refusal left, and it is the honest one.
|
||||||
|
4. On success: permute `nodes`, then remap through the permutation **everything that holds an
|
||||||
|
index** — `output`, `selected`, every `in_a`/`in_b`, `drag_node`, `selected_wire.node`. A
|
||||||
|
forgotten one is a silent mis-wire, so they are remapped in a single block with a comment
|
||||||
|
naming the set.
|
||||||
|
|
||||||
|
The session format is untouched: what is written is still a list in evaluation order. A file
|
||||||
|
written by an older build still loads, because the sort is an editor operation and an
|
||||||
|
already-ordered list sorts to itself.
|
||||||
|
|
||||||
|
`math_canvas_draw` stops gating a wire on `from < i` once this holds — but keeps the gate as a
|
||||||
|
**defensive skip**, because a `MathChannel` loaded from a file the editor did not write could
|
||||||
|
hold a forward reference, and drawing it would be drawing an edge the evaluator ignores.
|
||||||
|
|
||||||
|
### The armed-port highlight goes away
|
||||||
|
|
||||||
|
`pending_out` and the "input ports of later nodes light up" highlight are both deleted. They
|
||||||
|
exist to serve the two-click protocol and the list-order rule, and neither survives: with a
|
||||||
|
drag gesture the feedback is the live wire following the pointer, and with the re-sort every
|
||||||
|
input is a legal target. What replaces the highlight is a hover accent on the port under the
|
||||||
|
pointer during a `Wire` drag, which says the one thing that still matters — whether releasing
|
||||||
|
here will connect.
|
||||||
|
|
||||||
|
## The live validation line
|
||||||
|
|
||||||
|
`on_math_apply` validates in two steps today (`math_channel_valid()`, then an acquired-source
|
||||||
|
check the validator cannot make) and reports each in a modal `GtkAlertDialog`. A modal alert at
|
||||||
|
the end of a build is the worst time and place to be told; the graph is right there and can say
|
||||||
|
so continuously.
|
||||||
|
|
||||||
|
One extraction:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
/// Why the graph as it stands could not be saved, or "" when it could. Both checks the Apply
|
||||||
|
/// path made: `math_channel_valid()`'s graph rules and the dialog's own "a Source must name an
|
||||||
|
/// acquired channel", which the validator cannot make because only the dialog sees that list.
|
||||||
|
std::string math_dialog_problem(const MathDialog* d);
|
||||||
|
```
|
||||||
|
|
||||||
|
`math_dialog_sync()` — already the one place that rewrites the form from the model, and already
|
||||||
|
called from every mutation — sets the line's text and `gtk_widget_set_sensitive(apply, problem
|
||||||
|
.empty())`. An insensitive Apply with the reason beside it is a better refusal than a dialog
|
||||||
|
that must be dismissed.
|
||||||
|
|
||||||
|
`on_math_apply` keeps the check and loses the **dialogs**: it calls `math_dialog_problem()` and
|
||||||
|
returns on a non-empty answer, with no `GtkAlertDialog`. The guard has to stay even though the
|
||||||
|
button cannot be clicked while it would fire, because the probe calls `on_math_apply()` directly
|
||||||
|
and must not be able to save a broken graph through it. That `d->apply` pointer is new on
|
||||||
|
`MathDialog`; the button is built after the canvas today, so `math_dialog_sync()`'s first call
|
||||||
|
(at `src/ui.cpp:3972`, after the whole tree) already runs late enough to set it.
|
||||||
|
|
||||||
|
The line is a `GtkLabel` under the canvas, `error` CSS class when non-empty, and
|
||||||
|
`gtk_label_set_ellipsize(END)` with a tooltip carrying the full text — **the layout-must-not-
|
||||||
|
depend-on-content rule applies here as much as to the status bar**: this label sits in a dialog
|
||||||
|
whose canvas is `hexpand`, and a wrapping error message would otherwise widen the dialog on a
|
||||||
|
mere change of graph.
|
||||||
|
|
||||||
|
## The op picker on add
|
||||||
|
|
||||||
|
`add node` always makes a `MathOp::Gain` (`src/ui.cpp:3516`), so adding a low pass is two
|
||||||
|
actions and a dropdown hunt. It becomes a `GtkMenuButton` labelled `add` whose popover holds a
|
||||||
|
flat button per op, built once from `math_op_count()` / `math_op_at(i)` — the accessor order,
|
||||||
|
not the enum order, for the reason `CLAUDE.md` already records about writing an op's index.
|
||||||
|
Each button carries its op index with `g_object_set_data`, so there is no action-name plumbing
|
||||||
|
and nothing to parse.
|
||||||
|
|
||||||
|
The two behaviours that already work are kept: the new node becomes the output when the graph
|
||||||
|
`was_linear`, and placement is the existing grid. Placement gains one thing — a node is placed
|
||||||
|
at the **first free grid cell** rather than at `nodes.size()`, so the slot left by a deletion is
|
||||||
|
reused instead of the grid growing for ever. The overlap rule stands (`CLAUDE.md`: boxes must
|
||||||
|
not overlap, or `math_node_at()`'s topmost-first search returns the wrong one).
|
||||||
|
|
||||||
|
## Tidy / auto-layout
|
||||||
|
|
||||||
|
A `tidy` button lays the graph out left to right by dependency depth:
|
||||||
|
|
||||||
|
```
|
||||||
|
depth(i) = 0 for a Source or an unwired node
|
||||||
|
= 1 + max(depth of its wired inputs) // well-defined: inputs precede i
|
||||||
|
x = 24 + depth * (kNodeW + 48)
|
||||||
|
y = 20 + row_within_depth * (kNodeH + 22)
|
||||||
|
```
|
||||||
|
|
||||||
|
It needs no recursion and no visited set: the list is in evaluation order — the invariant the
|
||||||
|
re-sort now maintains — so the depth of node `i` depends only on nodes before it and a single
|
||||||
|
forward pass computes every depth. Then `math_fit_view()`, because a tidied graph that is off
|
||||||
|
screen is not tidy.
|
||||||
|
|
||||||
|
The column pitch is `kNodeW + 48`, which is 180 against the 160 the current grid uses: 160
|
||||||
|
leaves 28 units for the wire's 40-unit tread, so the curve doubles back on itself between
|
||||||
|
adjacent columns.
|
||||||
|
|
||||||
|
## The `Cancel` button closes rather than destroys
|
||||||
|
|
||||||
|
`src/ui.cpp:3962` connects `Cancel` to `gtk_window_destroy`:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
g_signal_connect_swapped(cancel, "clicked", G_CALLBACK(gtk_window_destroy), window);
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the defect `CLAUDE.md` documents under the math channel invariants — "a Cancel that
|
||||||
|
*destroyed* the window left `app.math_dialog` pointing at a dead widget: every later
|
||||||
|
`open_math_dialog()` took the 'already open' branch and presented it" — and it is still live.
|
||||||
|
The fix for it reached the Apply path (`src/ui.cpp:3731`) and the probe (`src/ui.cpp:6672`) and
|
||||||
|
never reached the button. It is `gtk_window_close`, one word, and the probe must assert it,
|
||||||
|
which it does not today: it only ever closes programmatically.
|
||||||
|
|
||||||
|
## What `[math-probe]` must assert
|
||||||
|
|
||||||
|
Every gesture above is reachable from the probe, because the handlers take the `MathDialog*`
|
||||||
|
and plain coordinates — which is already how the drag test at `src/ui.cpp:6694` works. The
|
||||||
|
probe is extended with one line per claim; each is an assertion stated as a `want` beside the
|
||||||
|
reading, in the style of the existing lines.
|
||||||
|
|
||||||
|
- `[math-probe] hit: output at edge+1 -> node N which=-1, at edge+9 -> node N which=-1
|
||||||
|
(want N N)` — the silent-disarm defect, read at two offsets inside the hit radius and outside
|
||||||
|
the box. Both must name the node. **This is the one line that would have caught the reported
|
||||||
|
bug**, and it is the reason the hit test returns the node rather than a sentinel.
|
||||||
|
- `[math-probe] wire: connect 0->1.a wired=W (want 0)` then a backwards
|
||||||
|
`[math-probe] wire: backwards connect 2->0.a order=[..] wired=W reordered=D (want 1)` — the
|
||||||
|
re-sort, with the permuted op order printed so a wrong remap is visible as a wrong list.
|
||||||
|
- `[math-probe] wire: self refused=D problem=[..] (want 1)` and
|
||||||
|
`[math-probe] wire: cycle refused=D problem=[..] (want 1)` — the two refusals, with the
|
||||||
|
cycle built by wiring a two-node pair both ways. `refused=1` **and** the edge absent.
|
||||||
|
- `[math-probe] wire: pick at (x,y) -> (node,which)=(N,K) delete -> in=-1 (want -1)` — the
|
||||||
|
pick samples the curve at its midpoint, which is the one place a wrong tolerance or a wrong
|
||||||
|
curve shows.
|
||||||
|
- `[math-probe] detach: drag from in 1.a to empty -> in=-1 (want -1)`, and
|
||||||
|
`[math-probe] detach: escape restores in=F (want F)` — the clear-on-begin and its undo, which
|
||||||
|
is the half that silently deletes an edge if Escape is forgotten.
|
||||||
|
- `[math-probe] view: zoom scale=S anchor kept dx=.. dy=.. (want 0.0 0.0)` — the graph point
|
||||||
|
under the pointer before and after a wheel step, which is the only reading that distinguishes
|
||||||
|
"zoomed about the pointer" from "zoomed about the origin". Both must be zero to a rounding.
|
||||||
|
- `[math-probe] view: fit scale=S box=[..] all_inside=D (want 1)`, and
|
||||||
|
`[math-probe] view: reset scale=S offset=(..) (want 1.000 (0,0))`.
|
||||||
|
- `[math-probe] view: pan dx=.. dy=.. offset=(..) (want ..)` plus a hit test **after** a pan and
|
||||||
|
a zoom — `port at the same screen point -> node N` — because a hit test that forgot the
|
||||||
|
inverse transform passes every test taken at the identity view.
|
||||||
|
- `[math-probe] tidy: depths=[..] columns=[..] overlaps=D (want 0)` — the overlap count is the
|
||||||
|
assertion (`CLAUDE.md`'s own rule), and the depths say the layout followed the graph.
|
||||||
|
- `[math-probe] validate: problem=[..] apply_sensitive=D` before and after wiring the missing
|
||||||
|
input: `(want non-empty 0)` then `(want empty 1)`.
|
||||||
|
- `[math-probe] cancel: dialog=cleared` — the `Cancel` button's `::clicked` emitted directly,
|
||||||
|
then `app.math_dialog` read back. `STILL SET` is the regression.
|
||||||
|
|
||||||
|
Two things the probe deliberately cannot assert, and which are therefore stated as design
|
||||||
|
constraints rather than checks: that the canvas takes **focus** on press (a synthetic press
|
||||||
|
does not run GTK's focus machinery), and that the live wire *looks* like it follows the pointer
|
||||||
|
(a draw is not readable from a probe). The second is bounded by the first assertion set — the
|
||||||
|
endpoint it is drawn from is `wire_x`/`wire_y`, which the drag test writes and can print.
|
||||||
|
|
||||||
|
The existing lines are all kept. `drag:` in particular stays as it is: the total-offset rule is
|
||||||
|
unchanged by any of this, and the gesture that exercises it is still a node-body drag.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Three places, following the project's existing split:
|
||||||
|
|
||||||
|
- **`CLAUDE.md`** — the `[math-probe]` bullet under Verification gains the new readings and
|
||||||
|
their `want`s; the math channel invariants gain the **hit-test-names-its-own-node** rule (a
|
||||||
|
port hit must not be re-derived with a box test), the **tolerance-is-a-screen-distance** rule,
|
||||||
|
the **list order is the editor's invariant, maintained by a topological re-sort** rule with
|
||||||
|
the index-remap set named, and a note that the `Cancel` path is now asserted.
|
||||||
|
- **`README.md`** — a subsection under the math channels describing the editor as it now
|
||||||
|
behaves, and the diagnosis of the silent disarm, since README is where "what was actually
|
||||||
|
built and why" lives.
|
||||||
|
- **`src/help/11-math.txt`** gains the four canvas gestures (drag a port to wire, drag a wired
|
||||||
|
input away to detach, click a wire and press Delete, wheel to zoom and drag the background to
|
||||||
|
pan) and the two buttons. It is an edit to an existing page, so `pages` stays 14 and
|
||||||
|
`[help] pages=14 parsed=14` must still hold — the markup is Pango and a page that fails to
|
||||||
|
parse renders a stand-in.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **The re-sort touching an index nothing remapped.** The mitigation is that the remap is one
|
||||||
|
block, the probe prints the permuted op order, and `selected`/`output` are both read back.
|
||||||
|
- **The key controller stealing a keystroke from the name entry.** It is on the canvas, which
|
||||||
|
must have focus; the probe cannot see this, so it is checked by hand once, by typing in the
|
||||||
|
name field with a wire selected.
|
||||||
|
- **The wheel controller fighting a future scrolled window.** There is none and this design
|
||||||
|
removes the reason for one, which is worth stating so a later "wrap the canvas in a
|
||||||
|
`GtkScrolledWindow`" is recognised as undoing the zoom rather than adding to it.
|
||||||
|
- **`GtkGestureDrag` and `GtkGestureClick` on one widget both claiming a press.** They already
|
||||||
|
coexist on this canvas; the change is that `pressed` now only selects and never mutates the
|
||||||
|
graph, which removes the ordering question rather than answering it.
|
||||||
+10
-3
@@ -183,9 +183,16 @@ std::uint32_t Acquisition::build_stamps(const udps_frame_t* frame, int time_sign
|
|||||||
used_stream_time = true;
|
used_stream_time = true;
|
||||||
break;
|
break;
|
||||||
case UDPS_TIME_FIRST_SAMPLE: {
|
case UDPS_TIME_FIRST_SAMPLE: {
|
||||||
|
// Only element 0 is meaningful here: the producer stamps the batch's first
|
||||||
|
// sample and says nothing about the rest, so the cadence has to space them --
|
||||||
|
// exactly as LAST_SAMPLE below dates its batch backwards from its anchor.
|
||||||
|
// Deriving a step from `value_of(n - 1)` instead cannot work: a scalar signal
|
||||||
|
// in Accumulate mode still occupies `num_samples` slots on the wire, and a
|
||||||
|
// producer fills them by repeating the anchor, so the step came out as zero
|
||||||
|
// and every sample of the frame landed on one x. The degeneracy guard below
|
||||||
|
// then threw the whole stream onto the arrival clock.
|
||||||
const double anchor = value_of(0);
|
const double anchor = value_of(0);
|
||||||
const double step = n > 1 ? (value_of(n - 1) - anchor) / double(n - 1) : period;
|
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * cadence;
|
||||||
for (std::uint32_t k = 0; k < n; ++k) stamps_[k] = anchor + double(k) * step;
|
|
||||||
used_stream_time = true;
|
used_stream_time = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -511,7 +518,7 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
time_acc_ += rel;
|
time_acc_ += rel;
|
||||||
++time_acc_n_;
|
++time_acc_n_;
|
||||||
if (tick % g == g - 1) {
|
if (tick % g == g - 1) {
|
||||||
store.time().push(static_cast<f32>(time_acc_ / static_cast<double>(time_acc_n_)));
|
store.time().push(time_acc_ / static_cast<double>(time_acc_n_));
|
||||||
time_acc_ = 0.0;
|
time_acc_ = 0.0;
|
||||||
time_acc_n_ = 0;
|
time_acc_n_ = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace udpscope::build_info {
|
||||||
|
|
||||||
|
/// The CMake project version, e.g. "0.1.0".
|
||||||
|
extern const char* const kVersion;
|
||||||
|
/// `git describe --always --dirty` at build time, or "unknown" if git was not available.
|
||||||
|
extern const char* const kCommit;
|
||||||
|
/// When the binary was configured/built, as "YYYY-MM-DD HH:MM UTC".
|
||||||
|
extern const char* const kTimestamp;
|
||||||
|
|
||||||
|
} // namespace udpscope::build_info
|
||||||
+13
-13
@@ -24,7 +24,7 @@ const plot::data::MinMaxPyramid& null_pyramid() {
|
|||||||
// --- ChannelBuffer ----------------------------------------------------------
|
// --- ChannelBuffer ----------------------------------------------------------
|
||||||
|
|
||||||
ChannelBuffer::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) {}
|
||||||
@@ -64,11 +64,11 @@ void ChannelBuffer::acquire() noexcept {
|
|||||||
|
|
||||||
void ChannelBuffer::invalidate() noexcept {
|
void ChannelBuffer::invalidate() noexcept {
|
||||||
published_ = 0;
|
published_ = 0;
|
||||||
window_x0_ = 0.0f;
|
window_x0_ = 0.0;
|
||||||
pyramid_.clear();
|
pyramid_.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
f32 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
f64 ChannelBuffer::time_of(u64 absolute_index) const noexcept {
|
||||||
// Channel sample `j` is produced on the tick that also pushes time slot
|
// 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 +102,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 +121,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 +131,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 +171,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 +189,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 +197,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 +302,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 {
|
||||||
|
|||||||
+17
-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;
|
||||||
@@ -71,16 +73,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 +98,10 @@ 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;
|
||||||
|
|
||||||
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 +111,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 +166,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; }
|
||||||
|
|
||||||
|
|||||||
+29
-13
@@ -26,8 +26,24 @@ 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;
|
||||||
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
|
||||||
@@ -102,7 +118,7 @@ class EventSeries final : public plot::data::Series {
|
|||||||
/// Indices need no rebasing: the pyramid is built from sample 0 and nothing is ever
|
/// 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 +128,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);
|
||||||
};
|
};
|
||||||
|
|||||||
+28
-24
@@ -38,7 +38,7 @@ std::string sanitise(const std::string& name) {
|
|||||||
/// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
|
/// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
|
||||||
/// easily sanitise to the same string.
|
/// easily sanitise to the same string.
|
||||||
std::vector<std::string> column_labels(const ExportTable& table) {
|
std::vector<std::string> column_labels(const ExportTable& table) {
|
||||||
std::vector<std::string> taken{"time", "stream_time"};
|
std::vector<std::string> taken{"time", "abs_seconds"};
|
||||||
std::vector<std::string> out;
|
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) {
|
||||||
@@ -106,24 +106,28 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
// What is on screen: a pause's frozen view, or the trigger's held event. Both are immutable
|
// 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] = abs_base + table.time[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);
|
||||||
@@ -141,23 +145,23 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
// the same window hold the same samples. The lock is against a structural change
|
// 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));
|
||||||
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_base + table.time[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);
|
||||||
@@ -179,7 +183,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
// `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy
|
// `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 +215,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;
|
||||||
@@ -239,7 +243,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
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);
|
||||||
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 +256,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) {
|
||||||
|
|||||||
+16
-7
@@ -24,13 +24,22 @@ struct ExportTable {
|
|||||||
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The x the plot shows and the crosshair reads: stream seconds in live mode,
|
/// The x the plot shows and the crosshair reads, which is **relative** in every mode: seconds
|
||||||
/// edge-relative seconds in `[-pre, +post]` while an event is held.
|
/// before now while streaming (so the newest sample is 0 and the window is `[-window, 0]`),
|
||||||
|
/// seconds from the edge in `[-pre, +post]` while an event is held, and seconds from the
|
||||||
|
/// instant of the freeze on a paused view.
|
||||||
std::vector<f64> time;
|
std::vector<f64> time;
|
||||||
/// The stream's own clock, so two exports taken in different modes line up. Equal to
|
/// The stream's own absolute clock -- the number the time signal carried, which is what the
|
||||||
/// `time` in live mode; `time + trigger_time` while an event is held, which is the one
|
/// source stamped the samples with -- so two exports taken in different modes, or from
|
||||||
/// case where the visible x is not the stream's.
|
/// different runs, line up on one timeline.
|
||||||
std::vector<f64> stream_time;
|
///
|
||||||
|
/// `f64 seconds` rather than the wire's integer, because the integer does not exist in this
|
||||||
|
/// process: `udps_frame_t::values` is a `const double*`, so the client has already widened
|
||||||
|
/// every value by the time acquisition sees it. At Unix-epoch magnitudes an `f64` resolves
|
||||||
|
/// about 240 ns, which is coarser than a 1 MS/s sample period -- so this column is the
|
||||||
|
/// *provenance* of a row and `time` is the column to difference. Its own `np.diff` is
|
||||||
|
/// legitimately not single-valued for that reason.
|
||||||
|
std::vector<f64> abs_seconds;
|
||||||
std::vector<Column> columns;
|
std::vector<Column> columns;
|
||||||
|
|
||||||
std::size_t rows() const noexcept { return time.size(); }
|
std::size_t rows() const noexcept { return time.size(); }
|
||||||
@@ -45,7 +54,7 @@ struct ExportTable {
|
|||||||
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
/// model access. In trigger mode nothing is locked: the `EventSnapshot` is immutable.
|
||||||
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
ExportTable collect_export(ScopeModel& model, RowSet& rows);
|
||||||
|
|
||||||
/// Writes a NumPy `.npy` file holding one structured array: `time` and `stream_time` as
|
/// Writes a NumPy `.npy` file holding one structured array: `time` and `abs_seconds` as
|
||||||
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
|
/// `<f8`, every signal as `<f4` -- the width the ring actually stores, so the file is
|
||||||
/// about half the size of an all-`f8` one and invents no precision.
|
/// about half the size of an all-`f8` one and invents no precision.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<b>What udpscope is</b>
|
||||||
|
|
||||||
|
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each with up
|
||||||
|
to 4 traces, and every row shares one time axis, so the whole figure scrolls together.
|
||||||
|
Acquisition runs on its own thread and writes a ring buffer sized from <tt>--buffer-mb</tt>;
|
||||||
|
the drawing thread reads whatever that ring holds when a frame starts, which is why a trace
|
||||||
|
never makes the window stutter.
|
||||||
|
|
||||||
|
The stream is the clock. Every interval you read on screen is measured between timestamps the
|
||||||
|
source sent, never your machine's, so a stalled source freezes the view instead of scrolling it
|
||||||
|
-- and a source whose clock steps still gets the window you asked for.
|
||||||
|
|
||||||
|
The time axis is <i>relative to now</i>. While streaming it is a fixed <tt>[-window .. 0]</tt>
|
||||||
|
with the newest sample at 0, so x reads as an age: <tt>-1.5s</tt> is a sample a second and a half
|
||||||
|
old. In trigger mode 0 is the event's edge instead, and a paused view keeps the ages it was
|
||||||
|
frozen with. The source's own clock is still there when you need it -- an export writes it as a
|
||||||
|
second column.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it with
|
||||||
|
no <tt>--server</tt> and pick one from the chooser, which lists the sessions you have opened
|
||||||
|
before. A unicast source serves one client at a time: if a second viewer connects, they take
|
||||||
|
turns and the status bar says so. To watch one source from several instances, use
|
||||||
|
<tt>--multicast</tt> with the group and <tt>--interface</tt> with the local address.
|
||||||
|
|
||||||
|
The signals arrive <i>disabled</i>. Nothing is acquired until you turn one on in the signal
|
||||||
|
list, and enabling is what starts acquisition -- so a freshly opened scope with an idle side
|
||||||
|
panel is working correctly, not failing to connect.
|
||||||
|
|
||||||
|
<b>The window</b>
|
||||||
|
|
||||||
|
• <b>The header bar</b> -- left to right: the time window (<tt>2 s</tt>), the trigger and the
|
||||||
|
pause, then the pane switches -- signal list, cursors, measurements, math, side panel -- and
|
||||||
|
the menu, which holds Fit to data, the layout editor, Export, Keyboard shortcuts, Help and
|
||||||
|
About.
|
||||||
|
|
||||||
|
• <b>The side panel</b> -- the trigger, cursors, measurements and math panes at the top, then
|
||||||
|
the signal list: one row per signal with its colour, an enable switch, its name and its unit.
|
||||||
|
Signals are dragged from here onto a plot row.
|
||||||
|
|
||||||
|
• <b>The plot</b> -- up to 8 rows, each with its own y axis and up to 4 traces, all on one
|
||||||
|
shared time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).
|
||||||
|
|
||||||
|
• <b>The status bar</b>, directly above the plot's bottom edge -- the state word, the stored
|
||||||
|
sample rate and the window, and the ⓘ beside it for packets, gaps and the buffer's own
|
||||||
|
figures. The second screenshot above is what a held capture looks like: <tt>event held</tt>,
|
||||||
|
the axis pinned to the event, and the trigger marker at the edge.
|
||||||
|
|
||||||
|
<b>The first five minutes</b>
|
||||||
|
|
||||||
|
1. <b>Connect.</b> With no <tt>--server</tt>, pick a stored session or type a host and port.
|
||||||
|
2. <b>Enable a signal</b> in the signal list. The plot stays empty for the first fraction of a
|
||||||
|
second while the first window fills -- the buffer holds only what has arrived.
|
||||||
|
3. <b>Drag it onto a row.</b> Drop a second signal on another row, or the same signal again for
|
||||||
|
a second view of it. The colour is per signal, so it is the same one wherever it appears.
|
||||||
|
4. <b>Set the window</b> with the control in the header bar: type a number, pick a unit, or step
|
||||||
|
a decade. The trigger's event window is a share of this span, so a window change moves it.
|
||||||
|
5. <b>Pause</b> (<tt>space</tt>) to look at a still picture; the producer keeps filling the
|
||||||
|
buffer behind it, and resuming shows what arrived meanwhile rather than a hole.
|
||||||
|
6. <b>Arm the trigger</b> (<tt>a</tt>) and wait for a latch: the view freezes on one event, and
|
||||||
|
you can zoom in on it. Disarming mid-capture keeps what the capture got.
|
||||||
|
7. <b>Measure it</b> -- the measurements pane computes a statistic of the visible window, and
|
||||||
|
<b>show on plot</b> marks it as a ruler or a pair of markers.
|
||||||
|
8. <b>Save the data</b> with Export visible range, which writes the plotted signals over the
|
||||||
|
window at the stored rate to <tt>.npy</tt> or <tt>.csv</tt>.
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• A signal nobody enables costs nothing -- no buffer, no socket work, no measurements.
|
||||||
|
• The window remembers itself. Rows, traces, the time window, calibration, which signals were
|
||||||
|
enabled, the math channels with their graphs, and the measurements are written per host and
|
||||||
|
port, and reloaded the next time you open that source.
|
||||||
|
• A very wide window on a fast stream is drawn from averaged groups of samples, so the rate in
|
||||||
|
the status bar falls: that is the anti-alias filter, not a fault -- striding would fold a fast
|
||||||
|
signal back as a slow one, which looks entirely plausible on screen.
|
||||||
|
• Every instrument the app computed -- the trigger marker, the measurement marks -- is locked to
|
||||||
|
the pointer's reach. The cursors are yours to drag; those are not.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<b>Choosing a source</b>
|
||||||
|
|
||||||
|
A source is a host and a port, optionally reached through a multicast group on a named
|
||||||
|
interface. Pass it on the command line, or let udpscope ask.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
Run with <tt>--server</tt> and the app connects straight away. Run <i>without</i>
|
||||||
|
<tt>--server</tt> and a chooser appears first: the sessions udpscope has stored, newest
|
||||||
|
first, plus a <b>New host…</b> form for one it has not seen. The chooser runs before any
|
||||||
|
buffer is allocated and before any socket is opened, so declining it costs nothing.
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• The list is the sessions that <i>named a signal</i>. A session whose streamer was down
|
||||||
|
stores empty slot names and is filtered out, which is what keeps the list the sources you
|
||||||
|
actually configured rather than every port you have ever typed.
|
||||||
|
• A stored row reconnects the way it was reached. The multicast group and the interface are
|
||||||
|
written inside the session file, because the file's name (<tt>host_port.toml</tt>) cannot
|
||||||
|
carry them.
|
||||||
|
• <tt>--multicast</tt> still needs <tt>--server</tt>: the CONFIG message is fetched from the
|
||||||
|
server, not from the group.
|
||||||
|
• The <tt>×</tt> beside a row deletes that session file. Nothing else is removed.
|
||||||
|
|
||||||
|
<b>Example</b>
|
||||||
|
|
||||||
|
<tt>udpscope</tt> with two stored sessions lists
|
||||||
|
<tt>127.0.0.1:8000 plots=1 signals=3</tt> and
|
||||||
|
<tt>10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9</tt>.
|
||||||
|
Activating the first reports <tt>[sources] picked 127.0.0.1:8000</tt> and continues into
|
||||||
|
the scope with that session's layout already loaded.
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<b>Signals and calibration</b>
|
||||||
|
|
||||||
|
The source's CONFIG message describes its signals: a name, a unit and a type. udpscope turns
|
||||||
|
each enabled signal into a <i>channel</i> — a ring of values beside the shared time ring —
|
||||||
|
and a channel is what a trace plots. Calibration is a scale and an offset applied at ingest.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
The side panel (<tt>s</tt>) lists every signal with a switch. The <b>Signal list</b> item in
|
||||||
|
the overflow menu opens the editor where the unit and the calibration live.
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
|
||||||
|
nobody acquires can never fire, so arming a trigger enables its signal for you.
|
||||||
|
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
|
||||||
|
history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
|
||||||
|
for a second after one of those is the ring refilling, not a fault.
|
||||||
|
• The stream's first signal is usually its <i>timestamp</i>, and that one is never acquired.
|
||||||
|
It cannot be plotted, measured or used as a math source, and udpscope refuses to put it in
|
||||||
|
a row rather than binding a trace that would read nothing for ever.
|
||||||
|
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
|
||||||
|
is one colour and the side panel's chip identifies it unambiguously. A signal on no row
|
||||||
|
gets a grey chip rather than none, so the panel's rows keep their height.
|
||||||
|
|
||||||
|
<b>Example</b>
|
||||||
|
|
||||||
|
A source declaring <tt>time</tt>, <tt>sine</tt>, <tt>ramp</tt> and <tt>noise</tt> yields three
|
||||||
|
plottable signals. Enable all three and the exit dump reads <tt>3 signals, 3 channels</tt>;
|
||||||
|
<tt>time</tt> is not among them.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<b>Rows, layout and drag & drop</b>
|
||||||
|
|
||||||
|
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
|
||||||
|
time axis and one pair of side margins, so their y labels line up, their data areas start and
|
||||||
|
end on the same two columns, and their windows cannot drift apart. The left margin holds the y
|
||||||
|
labels; the right one is empty, and is there so the newest tick's label and the newest sample's
|
||||||
|
ink have somewhere to land instead of sitting on the frame.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
Drag a signal's chip out of the side panel (<tt>s</tt>) and drop it on a row to add a trace.
|
||||||
|
Press <tt>l</tt>, or pick <b>Layout editor</b> from the overflow menu, to add and remove rows:
|
||||||
|
a <tt>×</tt> appears at the top left of each row and a <tt>+</tt> on each boundary between
|
||||||
|
rows, including above the first and below the last. The editor's own button in the top-right
|
||||||
|
corner leaves it.
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
|
||||||
|
traces already on a row the drop is refused.
|
||||||
|
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
|
||||||
|
puts what row 1 plotted onto row 2 and leaves the new row empty — the rows are not
|
||||||
|
renumbered under you.
|
||||||
|
• A trace is remembered by the signal's <i>name</i>, not by its channel number, because a new
|
||||||
|
CONFIG or an enable renumbers the channels and an index would silently re-point the trace.
|
||||||
|
• Adding or removing a row rebuilds the figure, which is cheap, but it also re-deals every
|
||||||
|
trace. A frozen trigger event survives the rebuild: the pinned window is restated rather
|
||||||
|
than re-framed on the live edge.
|
||||||
|
|
||||||
|
<b>Example</b>
|
||||||
|
|
||||||
|
With three traces on row 1, pressing <tt>l</tt> shows 3 buttons (one <tt>×</tt>, two
|
||||||
|
<tt>+</tt>). Clicking the <tt>+</tt> above row 1 gives 2 rows and 5 buttons, with the three
|
||||||
|
traces now on row 2 — the layout probe reports exactly this as
|
||||||
|
<tt>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</tt>.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<b>Time window</b>
|
||||||
|
|
||||||
|
The window is how much time the plot shows. Every row shares one window: the figure has a
|
||||||
|
single x link group, so the rows cannot drift apart. The trigger's event span follows it, and
|
||||||
|
the pre-trigger share is a share of <i>this</i> window.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
The header bar's window button opens a popover: type a number, pick a unit under it, or step a
|
||||||
|
decade with the arrows. The button's own label is a reading of the current window in a natural
|
||||||
|
unit, width-locked so a change of window cannot widen the header bar.
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• A window too long to store at the wire rate is <i>decimated, not refused</i>. The shared clock
|
||||||
|
takes a power-of-two stride, and each group of samples is <b>averaged</b> rather than strided,
|
||||||
|
so a tone above the stored Nyquist is attenuated instead of aliasing to a false low frequency.
|
||||||
|
• The stamp stored for a group is the group's <i>mean</i>, which is where the averaging puts the
|
||||||
|
value. Stamping at the end of a group would shift every trace half a stored sample late.
|
||||||
|
• Moving the window across a decimation boundary rebuilds the buffer and drops the history.
|
||||||
|
Walking it inside one decade is free.
|
||||||
|
• <b>This control is the only thing that changes the window.</b> A zoom, a pan or a fit moves the
|
||||||
|
view <i>inside</i> it and leaves it alone — which is the point of the previous bullet: a wheel
|
||||||
|
notch that redefined the window could re-plan the buffer and throw your samples away.
|
||||||
|
• The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge
|
||||||
|
found on a sample that is not in the buffer could not be cut out of it.
|
||||||
|
|
||||||
|
<b>Example</b>
|
||||||
|
|
||||||
|
1 MS/s with 200 MB of buffer. A 2 s window stores every sample (<tt>time x1</tt>). Widen it to
|
||||||
|
600 s and the stride becomes <tt>x16</tt>: 625 S/s stored, anti-aliased at 312.5 Hz. A 2 kHz
|
||||||
|
sine seen through that window reads <tt>y=[-0.059, 0.079]</tt> — attenuated to about 6%, not
|
||||||
|
folded down to a full-amplitude 125 Hz one.
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<b>Navigating the plot</b>
|
||||||
|
|
||||||
|
The plot pans and zooms with the mouse and with the keyboard. Every gesture acts on the row the
|
||||||
|
pointer is over, and because the rows share one time axis, an x change moves all of them.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
|
||||||
|
<b>Drag</b> pans · <b>Ctrl+drag</b> is a box zoom on x · <b>Alt+drag</b> a box zoom on y.
|
||||||
|
<b>+</b> and <b>-</b> zoom both axes; <b>Ctrl</b> with them restricts to x, <b>Alt</b> to y.
|
||||||
|
<b>Arrow keys</b> pan, <b>f</b> fits, <b>Escape</b> cancels a gesture in progress.
|
||||||
|
The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• The keyboard zoom's modifiers are <i>not</i> the wheel's, and that is deliberate: on common
|
||||||
|
layouts <tt>+</tt> <i>is</i> shift+<tt>=</tt>, so a bare zoom-in already arrives with Shift
|
||||||
|
set and Shift could not mean anything. Ctrl/Alt mirror the box-zoom drags instead.
|
||||||
|
• A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is
|
||||||
|
held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not
|
||||||
|
outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data.
|
||||||
|
• Nothing you do on the plot changes the <i>time window</i> — only the header control does. A
|
||||||
|
zoom or a pan moves the view inside the window; <b>f</b> (and Fit X axis) is the <i>undo</i> of a
|
||||||
|
zoom, so a 2 s window zoomed into reads 2 s again. The window is more than the axis: it is also
|
||||||
|
the trigger's event span and how the samples are stored, so a wheel notch that redefined it
|
||||||
|
would re-plan the buffer and drop your history.
|
||||||
|
• The axis does not scroll — the <i>data</i> does. A live axis is a fixed <tt>[-window .. 0]</tt>
|
||||||
|
with the newest sample at 0, so the labels stand still and the trace rolls under them. The
|
||||||
|
cursors stand still with the labels (they are positions on screen, so a cursor you placed stays
|
||||||
|
where you put it, over newer data each frame), while a measurement's marks travel with the
|
||||||
|
sample they measured and leave the view when it does.
|
||||||
|
• A live view is clamped to the window you asked for, not to what the ring happens to hold.
|
||||||
|
That is why a buffer that has only just started filling leaves its left-hand side empty
|
||||||
|
instead of squeezing the window down to the first few samples.
|
||||||
|
• Zooming hard into a held capture is exact. The renderer finds the visible samples by binary
|
||||||
|
search over the real timestamps, never over a nominal sample grid, so a capture whose clock
|
||||||
|
wandered does not drift away from its axis as you zoom.
|
||||||
|
• A trace holding far more samples than the plot has pixels is drawn <i>one pixel column at a
|
||||||
|
time</i>: each column is a single vertical line from the lowest to the highest value that falls
|
||||||
|
in it. Drawn as a polyline instead, such a window puts three or four near-vertical lines in
|
||||||
|
every column at slightly different sub-pixel positions, and how they overlap beats against the
|
||||||
|
pixel grid — which reads as bands of light and dark that <i>crawl</i> as you pan. One line per
|
||||||
|
column removes the beat, and it is also much faster: a 1 MS/s view costs a couple of thousand
|
||||||
|
line segments a frame instead of a couple of hundred thousand.
|
||||||
|
• Those columns are <i>graded by brightness</i>, the way a digital-phosphor scope reads: bright
|
||||||
|
where the signal spent its time, faint where it only passed through. A column's highest and
|
||||||
|
lowest value are the same whether the samples in it are spread evenly or piled at its two ends,
|
||||||
|
so the grading is what tells the two apart — the flat parts of a square wave come out solid and
|
||||||
|
the edges between them faint. It is read from a finer division of each column rather than from
|
||||||
|
the line that is drawn, and it costs about a millisecond a frame.
|
||||||
|
|
||||||
|
<b>Example</b>
|
||||||
|
|
||||||
|
Pause a live view, then press <tt>Ctrl++</tt>: x narrows and y does not move. Press
|
||||||
|
<tt>Alt++</tt>: y narrows and x does not. Eight doublings into a held 2 s capture, the trace
|
||||||
|
still reaches both margins — the zoom probe reports <tt>gap_px</tt> and <tt>end_px</tt> within
|
||||||
|
a pixel or two at every step.
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<b>Pause and play</b>
|
||||||
|
|
||||||
|
Pause freezes the <i>display</i>, not the acquisition. The producer keeps filling the ring at
|
||||||
|
full rate; the view binds an immutable snapshot of the window that was on screen, and the axis
|
||||||
|
is pinned to it.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
The pause button in the toolbar, or <tt>Space</tt>. Press it again to resume, which rebinds the
|
||||||
|
live ring and puts the window back on the newest data.
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• Nothing is lost while paused. Because the producer never stops, resuming shows current data
|
||||||
|
with no hole in the history behind it.
|
||||||
|
• The axis is pinned to the <i>snapshot's own stamps</i>, not to the window that was requested.
|
||||||
|
A window reaching back past the oldest retained sample yields a shorter snapshot, and the
|
||||||
|
pinned axis is that shorter span — so you see a narrower view rather than an empty margin.
|
||||||
|
• A paused view is pinned exactly as a held trigger event is: zoom in freely, but a pan or a
|
||||||
|
zoom out is clamped to the snapshot's data.
|
||||||
|
• Pausing is the easiest way to measure a layout or read a cursor precisely, because nothing
|
||||||
|
under the pointer moves between frames.
|
||||||
|
• The status bar says how long you have been paused — wall time, since the stream itself never
|
||||||
|
stopped. It is there so a frozen picture cannot be mistaken for a live one that went quiet.
|
||||||
|
|
||||||
|
<b>Example</b>
|
||||||
|
|
||||||
|
Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads
|
||||||
|
<tt>[-1.40 .. 0.00]</tt> rather than <tt>[-2.00 .. 0.00]</tt> with 0.6 s of blank. The pause
|
||||||
|
probe reports <tt>ring_produced</tt> still growing across the pause and
|
||||||
|
<tt>event_size</tt> non-zero exactly while paused.
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<b>Trigger</b>
|
||||||
|
|
||||||
|
The trigger watches one signal for an edge through a level and captures a window around it.
|
||||||
|
The captured window is frozen on screen until you re-arm.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
The trigger button in the toolbar (<tt>t</tt>) reveals the trigger panel: the signal, the level,
|
||||||
|
the edge (rising, falling, either), the mode (Normal or Single) and the pre-trigger share.
|
||||||
|
<b>Arm</b> starts watching; <tt>a</tt> arms and disarms from the keyboard.
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• Arming <i>enables</i> the signal if it was off, because a trigger on a signal nobody acquires
|
||||||
|
can never fire.
|
||||||
|
• The pre-trigger share is a share of the <i>display window</i>, not of the ring. A 2 s window
|
||||||
|
at 50% captures 1 s before the edge and 1 s after.
|
||||||
|
• There are four display states, and the status bar names each: <tt>armed</tt> (watching,
|
||||||
|
nothing drawn yet), <tt>capturing</tt> (the edge was accepted and the post window is filling
|
||||||
|
<i>in view</i>, with the marker at x = 0), <tt>event held</tt> (the frozen capture) and
|
||||||
|
<tt>disarmed</tt>. A held capture is never quietly replaced by live data.
|
||||||
|
• The first edge after arming is only accepted once the history reaches back a full pre-trigger
|
||||||
|
window, so a capture may legitimately be shorter than you asked for. The axis is pinned to
|
||||||
|
what the capture actually got.
|
||||||
|
• Disarming <i>during</i> a capture publishes what the capture got to, so the picture on screen
|
||||||
|
at that moment stays on screen. No event fired, so the event count does not move.
|
||||||
|
• The trigger reads the <b>stored</b> signal — decimated and averaged — not the wire signal.
|
||||||
|
A spike narrower than one decimation group is filtered away rather than triggering on data
|
||||||
|
the buffer does not contain.
|
||||||
|
|
||||||
|
<b>Example</b>
|
||||||
|
|
||||||
|
A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans
|
||||||
|
<tt>[-1.000 .. 1.000]</tt> with <tt>n=20001</tt> samples at 10 kS/s — the span times the real
|
||||||
|
rate, plus one — and the marker sits at x = 0 throughout.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<b>Cursors</b>
|
||||||
|
|
||||||
|
Cursors read values off the traces. There are two kinds: one <i>shared vertical pair</i> that
|
||||||
|
every row carries at the same time, and <i>horizontal rulers</i>, one pair per row, that belong
|
||||||
|
to that row alone.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
The cursors button in the toolbar, or <tt>c</tt>. The pane below the plot shows the readings and
|
||||||
|
owns a <b>Reset</b> button. Drag a marker to move it. The mode dropdown offers four modes:
|
||||||
|
<tt>vertical</tt> (the shared time pair), <tt>horizontal</tt> (the per-row rulers),
|
||||||
|
<tt>both</tt>, and <tt>mix</tt>.
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• The vertical pair is <i>one</i> time for the whole figure, so moving it on any row moves it on
|
||||||
|
all of them. That is what makes <tt>dt</tt> meaningful across rows.
|
||||||
|
• A ruler belongs to its row and nothing shares it, so two rows' rulers are independent.
|
||||||
|
• <tt>mix</tt> is the reading mode: one line per <i>bound</i> trace, with the trace's own values
|
||||||
|
at each marker and their difference. A slot with a name but no channel behind it is not a
|
||||||
|
trace and gets no line.
|
||||||
|
• A value is the <i>nearest stored sample's</i>, never a number between two samples. Zoom in past
|
||||||
|
the sample spacing and the line between two samples is something the plot drew, not data —
|
||||||
|
so a reading off it would be the drawing. The hover crosshair reads the same way.
|
||||||
|
• Every reading carries a <i>metric prefix</i> so the digits sit in <tt>1..999</tt>:
|
||||||
|
<tt>10.52 mV</tt>, not <tt>0.01 V</tt>. Two decimals is all an instrument landing on an
|
||||||
|
<tt>f32</tt> sample can claim, so without the prefix a small value would read <tt>0.00</tt>
|
||||||
|
and the reading would be gone. A stream that declares no unit gets the prefix anyway, written
|
||||||
|
tight against the number (<tt>585.24m</tt>) — the space separates the number from its unit,
|
||||||
|
and a spaced <tt>m</tt> with nothing after it would read as metres.
|
||||||
|
• A marker past the newest sample reads <tt>--</tt>. With only one marker over data the row
|
||||||
|
reads <tt>partial</tt>: a difference between a number and nothing is not a number.
|
||||||
|
• In <tt>mix</tt> each marker also places a crossing annotation per trace, which is what lets
|
||||||
|
you see on the plot which sample a number came from. It is <i>on that sample</i>, so when the
|
||||||
|
marker falls between two it sits a little off its own line: the line is where you put the
|
||||||
|
marker, the dot is the datum being quoted. The hover crosshair's mark snaps the same way, and
|
||||||
|
is drawn larger than a trace's sample dots so it does not read as one more sample.
|
||||||
|
• The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
|
||||||
|
time row, by design.
|
||||||
|
• A cursor <i>stays where you put it</i> while a live view rolls under it, which is what a bench
|
||||||
|
scope does: its <tt>T0</tt> is an age, so a marker left at <tt>-1.5s</tt> keeps reading the
|
||||||
|
sample a second and a half old as the stream advances. A measurement's marks do the opposite —
|
||||||
|
they travel with the sample they measured — because a cursor is a question you asked and a mark
|
||||||
|
is an answer about one particular sample.
|
||||||
|
|
||||||
|
<b>Example</b>
|
||||||
|
|
||||||
|
Three traces in <tt>mix</tt> mode with both markers over data: 3 lines and 6 crossing marks,
|
||||||
|
each line reading like <tt>sine 0.31 V -0.84 V -1.15 V</tt>. Move the second marker past the
|
||||||
|
newest sample and the third column reads <tt>--</tt>.
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<b>Measurements</b>
|
||||||
|
|
||||||
|
A measurement is a statistic of the <i>visible window</i> of one signal, recomputed as that
|
||||||
|
window moves. The pane lists them with their values; each can also draw its result on the plot.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
The measurements button in the toolbar, or <tt>m</tt>. The pane's <tt>+</tt> adds one; clicking
|
||||||
|
a row opens the dialog where the signal, the type, the threshold share and the on-plot marks are
|
||||||
|
set, with <b>Save</b> and <b>Delete</b>.
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• The types are average, top, bottom, min, max, peak to peak, amplitude, pulse duration, period
|
||||||
|
and frequency. <i>top</i> and <i>bottom</i> are the means of the upper and lower halves, which
|
||||||
|
is why a unit sine reads 0.637 (2/π) rather than 1.
|
||||||
|
• The on-plot marks come in two kinds and the mix is the reading: a <i>level</i> mark is a ruler
|
||||||
|
at a value, a <i>time</i> mark is a marker at a position. An average draws one level mark; a
|
||||||
|
max draws a level mark <i>and</i> a time mark at the first sample that reached it.
|
||||||
|
• Marks cannot be dragged. A measurement mark says what the measurement measured, so it is
|
||||||
|
created unselectable — as is the trigger marker.
|
||||||
|
• A pass is throttled to a cadence rather than run every frame, so a ruler over a live view is
|
||||||
|
<i>stepped</i>. The number and the ruler come from the same pass, so they always agree.
|
||||||
|
• An invalid pass keeps its <i>level</i> marks and drops its <i>time</i> marks. A level is a
|
||||||
|
property of the signal and outlives the window; a position inside the window does not. That is
|
||||||
|
what stops the rulers blinking off at a mode change.
|
||||||
|
• A crossing is counted with <i>hysteresis</i>, not on a bare level: the thresholds are a tenth of
|
||||||
|
the window's peak to peak either side of the share, and an edge is counted only once the signal
|
||||||
|
has passed the far one. Period, frequency and pulse duration all depend on it. The mid level is
|
||||||
|
exactly where a waveform moves fastest, so any interferer faster than the fundamental recrosses a
|
||||||
|
single level several times per real crossing — a 5 Hz sine with a 500 Hz tone at a tenth of the
|
||||||
|
amplitude reads 39 Hz without the band and 5 Hz with it. The band holds to an interferer a third
|
||||||
|
of the waveform's own size.
|
||||||
|
• The band is clamped to stay inside the data, which is what keeps a threshold set near an extreme
|
||||||
|
usable: a fixed band there would sit above every sample and the measurement would read nothing.
|
||||||
|
A share near an extreme still leaves the band little room, so it rejects less — which is one
|
||||||
|
reason period and frequency offer no share to set.
|
||||||
|
• A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible
|
||||||
|
invented number. Frequency over a window holding one edge is the usual case.
|
||||||
|
• <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds;
|
||||||
|
every other type carries the channel's own unit. A value is written with a <i>metric prefix</i>
|
||||||
|
so its digits sit in <tt>1..999</tt> — <tt>636.62 mV</tt>, not <tt>0.64 V</tt> — including when
|
||||||
|
the stream declares no unit at all, which is always the case for a math channel: there the
|
||||||
|
prefix goes tight against the number (<tt>636.62m</tt>), since two decimals of an unprefixed
|
||||||
|
small value is <tt>0.00</tt>.
|
||||||
|
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
|
||||||
|
window. The values are still exact: a bucket's sum is a sum.
|
||||||
|
|
||||||
|
<b>Example</b>
|
||||||
|
|
||||||
|
On a 1 Hz ±1 sine: <tt>average=0</tt>, <tt>top=0.636620</tt>, <tt>bottom=-0.636620</tt>,
|
||||||
|
<tt>max=1</tt>, <tt>min=-1</tt>, <tt>peak to peak=2</tt>, <tt>amplitude=1</tt>,
|
||||||
|
<tt>pulse duration=0.5 s</tt> (duty 0.5), <tt>period=1 s</tt>, <tt>frequency=1 Hz</tt>. Over a
|
||||||
|
2 M-sample window those two passes read about 0.1% of the samples.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<b>Math channels</b>
|
||||||
|
|
||||||
|
A math channel is a signal computed from other signals. You build it as a small graph — source
|
||||||
|
nodes, operations, one output — and udpscope then treats the result as an ordinary channel.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel; opening one
|
||||||
|
shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
|
||||||
|
the canvas, click an output port then an input port to wire them, drag a box to move it. The
|
||||||
|
orange dot marks the output node.
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
|
||||||
|
cycle is impossible by construction rather than rejected by a check.
|
||||||
|
• A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted,
|
||||||
|
measured, exported, and used as a source for another math channel.
|
||||||
|
• The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
|
||||||
|
Hz means the rate the filter will actually run at. Change the window across a decimation
|
||||||
|
boundary and the coefficients are rebuilt for the new rate.
|
||||||
|
• A source may only name an <i>acquired</i> channel. The stream's timestamp signal is not one:
|
||||||
|
a graph sourced from it validates and then reads NaN for ever, so the dropdown does not
|
||||||
|
offer it.
|
||||||
|
• The output defaults to the <i>last</i> node, and a node added to a linear graph becomes the
|
||||||
|
output. Choosing an earlier node explicitly is respected.
|
||||||
|
• Every numeric field starts at its own identity value — gain 1, offset 0 — not at the bottom
|
||||||
|
of its range.
|
||||||
|
|
||||||
|
<b>Example</b>
|
||||||
|
|
||||||
|
<tt>[sine] → [gain x2]</tt> gives a channel reading <tt>y=[-2, 2]</tt>. A 100 Hz low pass on the
|
||||||
|
same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at
|
||||||
|
0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel must hold exactly as many samples as
|
||||||
|
the shared clock has time slots.
|
||||||
|
|
||||||
|
<b>Why an operation sometimes does nothing</b>. A graph's <i>output</i> is the node whose value
|
||||||
|
becomes the channel, and it is drawn as the orange dot. A new graph is built source, then operation,
|
||||||
|
and the last node added becomes the output -- but a graph whose output is left on the source is
|
||||||
|
valid and simply passes its input through, which is the usual reason a gain or a filter appears to
|
||||||
|
have no effect. <i>Set as output</i> moves the dot.
|
||||||
|
|
||||||
|
<b>Save refuses an incomplete graph</b>, and says why: an input that is not connected, no output
|
||||||
|
chosen, or a <i>channel</i> node naming a signal the scope is not acquiring. Only the channels being
|
||||||
|
acquired are offered, because a source that is not acquired reads as no data for ever -- and a graph
|
||||||
|
that reads NaN is harder to diagnose than one that would not save.
|
||||||
|
|
||||||
|
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
|
||||||
|
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
|
||||||
|
against the <i>stored</i> rate, which is the wire rate until a wide window makes the buffer decimate
|
||||||
|
-- the status line's rate is that number, and the filters are rebuilt whenever it changes.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<b>Saving and export</b>
|
||||||
|
|
||||||
|
Export writes the <i>visible range</i> of every plotted trace to a file: NumPy <tt>.npy</tt> or
|
||||||
|
<tt>.csv</tt>, chosen by the extension you type.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
<b>Export visible range…</b> in the overflow menu, or <tt>Ctrl+E</tt>. A file chooser asks where;
|
||||||
|
the format follows the path's extension.
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• The table is collected at the moment you click, not when the chooser returns. A live view keeps
|
||||||
|
scrolling while a dialog is up, so a late collection would write a window you never saw.
|
||||||
|
• The rows are the <b>stored samples</b>, never the LOD buckets the plot draws from. A row count
|
||||||
|
far below the span times the stored rate means something cut the table from the pyramid.
|
||||||
|
• The stored rate is not always the wire rate. A window long enough to need decimation is stored
|
||||||
|
averaged at a lower rate, and the export carries <i>that</i> — read the status bar's ⓘ popover
|
||||||
|
for the figure before trusting a sample count.
|
||||||
|
• Two time columns. <tt>time</tt> is the display timeline — negative ages ending at 0 while
|
||||||
|
streaming, offsets from the trigger edge while an event is held — so it is the x you read a
|
||||||
|
value at on screen. <tt>abs_seconds</tt> is the source's own clock for the same sample, which
|
||||||
|
is what lets two exports taken in different modes line up. Both are strictly increasing and
|
||||||
|
evenly spaced; duplicate or backwards stamps would mean the file held two clocks.
|
||||||
|
• An export taken <i>mid-capture</i> writes the partly filled window that is on screen, which is
|
||||||
|
shorter than the event span. Export outside a capture if you want the whole event.
|
||||||
|
|
||||||
|
<b>Example</b>
|
||||||
|
|
||||||
|
A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
|
||||||
|
(<tt>time</tt>, <tt>abs_seconds</tt>, three signals). The <tt>.npy</tt> is
|
||||||
|
<tt>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</tt> bytes. Read it back with
|
||||||
|
<tt>pd.DataFrame(np.load(path))</tt>.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<b>Sessions and settings</b>
|
||||||
|
|
||||||
|
udpscope stores one session file per source, so reopening a source gives you the scope you
|
||||||
|
closed. There is no global preferences file: the session <i>is</i> the settings.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
Nothing to open — the file is written as you work. Its path is in the About dialog, and the
|
||||||
|
directory is <tt>$XDG_CONFIG_HOME/udpscope</tt>, or <tt>~/.config/udpscope</tt> when that is
|
||||||
|
unset. Each file is named <tt><host>_<port>.toml</tt>.
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• What a session persists: the time window, the rows and each row's slot signals, the
|
||||||
|
calibration, which signals are enabled, the math channels with their graphs, and the
|
||||||
|
measurements pane.
|
||||||
|
• Signal identity is the <i>name</i> throughout, because a new CONFIG renumbers the channels.
|
||||||
|
• A file written by a run against a source whose streamer was down would name no signals at all,
|
||||||
|
so such a run does <b>not</b> write: a configured session is never overwritten by a run that
|
||||||
|
configured nothing.
|
||||||
|
• The multicast group and the interface live <i>inside</i> the file, because its name cannot
|
||||||
|
carry them. A file from an older build has neither key and loads as unicast on the default
|
||||||
|
route.
|
||||||
|
• The startup chooser lists only files that name at least one signal, which is what keeps it the
|
||||||
|
sources you configured rather than every port you have ever tested against.
|
||||||
|
• Deleting a file is a supported way to start a source over; the chooser's <tt>×</tt> does
|
||||||
|
exactly that.
|
||||||
|
|
||||||
|
<b>Example</b>
|
||||||
|
|
||||||
|
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1 and set a 2 s window.
|
||||||
|
<tt>~/.config/udpscope/127.0.0.1_8000.toml</tt> now holds <tt>rows=1 window_s=2</tt> with
|
||||||
|
<tt>signals = ["sine", "ramp", "noise", ""]</tt>, and the next run reports
|
||||||
|
<tt>layout loaded from …/127.0.0.1_8000.toml</tt> with those signals already acquired.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<b>Status bar and diagnostics</b>
|
||||||
|
|
||||||
|
The status bar is two tiers. The bar itself is one glance line — the state, the rate and the
|
||||||
|
window — and everything else is behind the ⓘ button at its right-hand end.
|
||||||
|
|
||||||
|
<b>How to reach it</b>
|
||||||
|
|
||||||
|
The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popover.
|
||||||
|
|
||||||
|
<b>Worth knowing</b>
|
||||||
|
|
||||||
|
• The badge is one glyph in four colours: green <i>alternating</i> lit and dim means data is
|
||||||
|
arriving, and a steady colour means it is not. A badge that never blinks is not a working badge.
|
||||||
|
Of the two steady colours, <i>grey</i> is no stream at all or a pipeline you stopped (the
|
||||||
|
tooltip says which), and <i>yellow</i> is a source that answered but has sent no configuration.
|
||||||
|
• The state field is one word from a fixed set: <tt>connecting</tt>, <tt>live</tt>,
|
||||||
|
<tt>paused</tt>, <tt>armed</tt>, <tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. Two
|
||||||
|
states add one field each: in trigger mode, how long ago the last event was; while paused, how
|
||||||
|
long you have been paused.
|
||||||
|
• <tt>connecting</tt> means there is no <i>signal list</i> yet — either no session or a session
|
||||||
|
with no configuration behind it. Every other word claims a signal list, so until one arrives
|
||||||
|
there is nothing for them to describe: nothing can be enabled and no data is owed.
|
||||||
|
• The paused figure is <i>wall</i> time, not stream time — the producer never stops while paused,
|
||||||
|
so it answers "how long have I been looking at this picture".
|
||||||
|
• Nothing on the bar may change width with the stream, because the bar sits directly above the
|
||||||
|
plot and a wider label would relayout it. That is why the rate collapses to one figure or a
|
||||||
|
range rather than listing per channel, and why an elapsed time steps to a coarser unit instead
|
||||||
|
of growing digits.
|
||||||
|
• <tt>gaps</tt> counts packets the receiver knows it missed. It is the number that matters: a
|
||||||
|
non-zero count means samples are absent from the buffer, not merely late.
|
||||||
|
• <tt>reconnects</tt> counts sessions re-established with the source. Over unicast a climbing
|
||||||
|
count usually means a second client is taking the source's single session.
|
||||||
|
• The starvation notice — <i>connected, but no data is arriving</i> — is that same situation seen
|
||||||
|
from the other side: the signal list is here, so something took the stream away. A source that
|
||||||
|
has sent <i>no</i> configuration gets a different notice, <i>trying to connect</i>, because
|
||||||
|
nothing was taken away — pointing the app at a port where nothing answers is that case, and the
|
||||||
|
usual cause is a streamer that is not running or is serving another port.
|
||||||
|
• <tt>stored</tt> versus <tt>wire</tt> in the popover is the decimation. They differ exactly
|
||||||
|
when the window is too long to store at full rate.
|
||||||
|
|
||||||
|
<b>Example</b>
|
||||||
|
|
||||||
|
The bar reads <tt>● live · 10 kS/s · 2 s window</tt>. Behind ⓘ:
|
||||||
|
<tt>Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3 channels</tt>,
|
||||||
|
<tt>Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire 10 kS/s)</tt>,
|
||||||
|
<tt>Plot 1 3 traces · 195000 samples</tt>, <tt>Reconnects 0</tt>.
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
The help pages, compiled into the binary for the same reason the icons are: udpscope is
|
||||||
|
run straight out of the build tree and has no install step, so a search path would have
|
||||||
|
to be located at runtime and the failure mode of not locating it is a help window full
|
||||||
|
of missing pages.
|
||||||
|
-->
|
||||||
|
<gresources>
|
||||||
|
<gresource prefix="/org/udpscope/help">
|
||||||
|
<file>01-overview.txt</file>
|
||||||
|
<file>02-source.txt</file>
|
||||||
|
<file>03-signals.txt</file>
|
||||||
|
<file>04-layout.txt</file>
|
||||||
|
<file>05-window.txt</file>
|
||||||
|
<file>06-navigate.txt</file>
|
||||||
|
<file>07-pause.txt</file>
|
||||||
|
<file>08-trigger.txt</file>
|
||||||
|
<file>09-cursors.txt</file>
|
||||||
|
<file>10-measurements.txt</file>
|
||||||
|
<file>11-math.txt</file>
|
||||||
|
<file>12-export.txt</file>
|
||||||
|
<file>13-sessions.txt</file>
|
||||||
|
<file>14-status.txt</file>
|
||||||
|
|
||||||
|
<!-- The screenshots live with the user documentation (docs/); the resource aliases
|
||||||
|
them under `img/` so a help page can name one by resource path. The path is relative
|
||||||
|
to this manifest's directory. -->
|
||||||
|
<file alias="img/main-window.png">../../docs/img/main-window.png</file>
|
||||||
|
<file alias="img/trigger-capture.png">../../docs/img/trigger-capture.png</file>
|
||||||
|
<file alias="img/math-editor.png">../../docs/img/math-editor.png</file>
|
||||||
|
</gresource>
|
||||||
|
</gresources>
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
#include "help_window.hpp"
|
||||||
|
|
||||||
|
#include <adwaita.h>
|
||||||
|
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include "build_info.hpp"
|
||||||
|
#include "layout_store.hpp"
|
||||||
|
#include "shortcuts.hpp"
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// Nulled by each window's own close handler, so a second open cannot present a dead widget.
|
||||||
|
/// One pointer per window, file-scope because there is one application.
|
||||||
|
///
|
||||||
|
/// The help window is a `GtkWindow`. The other two are `AdwDialog`s, which are **not**
|
||||||
|
/// `GtkWindow`s: they have no `::close-request`, so they clear on `::closed` and close with
|
||||||
|
/// `adw_dialog_close()`.
|
||||||
|
GtkWidget* g_help = nullptr;
|
||||||
|
AdwDialog* g_about = nullptr;
|
||||||
|
AdwDialog* g_shortcuts = nullptr;
|
||||||
|
|
||||||
|
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
||||||
|
(void)window;
|
||||||
|
*static_cast<GtkWidget**>(slot) = nullptr;
|
||||||
|
return FALSE; // let the default handler destroy it
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared by both `AdwDialog`s: the pointer to clear is the `user_data`, so there is one
|
||||||
|
/// handler rather than one per dialog.
|
||||||
|
void on_dialog_closed(AdwDialog* dialog, gpointer slot) {
|
||||||
|
(void)dialog;
|
||||||
|
*static_cast<AdwDialog**>(slot) = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct HelpPage {
|
||||||
|
const char* section; ///< the group heading in the sidebar
|
||||||
|
const char* title; ///< the row's label and the page's heading
|
||||||
|
const char* resource; ///< a path under /org/udpscope/help
|
||||||
|
/// The screenshots shown above the text, as resource paths and a null terminator. A page
|
||||||
|
/// with none is the common case and the reason this is a list rather than one field: the
|
||||||
|
/// overview carries two, the window and a held capture.
|
||||||
|
const char* const* images = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Adding a page is one .txt file, one <file> line in the manifest and one row here.
|
||||||
|
/// The order here is the order the sidebar shows; `section` groups consecutive rows.
|
||||||
|
/// How tall a page's screenshots are drawn. They are scaled to fit this *height* and centre
|
||||||
|
/// themselves in it, so a 1500x950 capture is readable without a horizontal scrollbar.
|
||||||
|
constexpr int kHelpShotHeight = 260;
|
||||||
|
|
||||||
|
/// The screenshots each page carries, in the order it shows them.
|
||||||
|
constexpr const char* kOverviewShots[] = {"/org/udpscope/help/img/main-window.png",
|
||||||
|
"/org/udpscope/help/img/trigger-capture.png",
|
||||||
|
nullptr};
|
||||||
|
constexpr const char* kTriggerShots[] = {"/org/udpscope/help/img/trigger-capture.png",
|
||||||
|
nullptr};
|
||||||
|
constexpr const char* kMathShots[] = {"/org/udpscope/help/img/math-editor.png", nullptr};
|
||||||
|
|
||||||
|
constexpr HelpPage kPages[] = {
|
||||||
|
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt", kOverviewShots},
|
||||||
|
{"Getting started", "Choosing a source", "/org/udpscope/help/02-source.txt"},
|
||||||
|
{"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.txt"},
|
||||||
|
{"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"},
|
||||||
|
{"Viewing", "Time window", "/org/udpscope/help/05-window.txt"},
|
||||||
|
{"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"},
|
||||||
|
{"Viewing", "Pause and play", "/org/udpscope/help/07-pause.txt"},
|
||||||
|
{"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt", kTriggerShots},
|
||||||
|
{"Analysis", "Cursors", "/org/udpscope/help/09-cursors.txt"},
|
||||||
|
{"Analysis", "Measurements", "/org/udpscope/help/10-measurements.txt"},
|
||||||
|
{"Analysis", "Math channels", "/org/udpscope/help/11-math.txt", kMathShots},
|
||||||
|
{"Output and state", "Saving and export", "/org/udpscope/help/12-export.txt"},
|
||||||
|
{"Output and state", "Sessions and settings", "/org/udpscope/help/13-sessions.txt"},
|
||||||
|
{"Output and state", "Status bar and diagnostics", "/org/udpscope/help/14-status.txt"},
|
||||||
|
};
|
||||||
|
constexpr int kPageCount = static_cast<int>(sizeof(kPages) / sizeof(kPages[0]));
|
||||||
|
|
||||||
|
/// The page's markup, or a one-line stand-in. `ok` reports whether the real body loaded
|
||||||
|
/// *and* parsed: a missing resource and markup Pango rejects both render the stand-in,
|
||||||
|
/// because a help window that fails to open is worse than one page that says so.
|
||||||
|
std::string load_page(const HelpPage& page, bool* ok) {
|
||||||
|
*ok = false;
|
||||||
|
GError* error = nullptr;
|
||||||
|
GBytes* bytes = g_resources_lookup_data(page.resource, G_RESOURCE_LOOKUP_FLAGS_NONE,
|
||||||
|
&error);
|
||||||
|
if (bytes == nullptr) {
|
||||||
|
if (error != nullptr) g_error_free(error);
|
||||||
|
return "<i>This page is missing from the build.</i>";
|
||||||
|
}
|
||||||
|
gsize size = 0;
|
||||||
|
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &size));
|
||||||
|
std::string body(data, size);
|
||||||
|
g_bytes_unref(bytes);
|
||||||
|
|
||||||
|
if (!pango_parse_markup(body.c_str(), -1, 0, nullptr, nullptr, nullptr, &error)) {
|
||||||
|
if (error != nullptr) g_error_free(error);
|
||||||
|
return "<i>This page could not be displayed: its markup is malformed.</i>";
|
||||||
|
}
|
||||||
|
*ok = true;
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer body_label) {
|
||||||
|
if (row == nullptr) return;
|
||||||
|
const int idx = GPOINTER_TO_INT(g_object_get_data(G_OBJECT(row), "help-page"));
|
||||||
|
if (idx < 0 || idx >= kPageCount) return;
|
||||||
|
bool ok = false;
|
||||||
|
const std::string body = load_page(kPages[idx], &ok);
|
||||||
|
gtk_label_set_markup(GTK_LABEL(body_label), body.c_str());
|
||||||
|
// The screenshots above it, if this page has any. The column is the label's previous sibling
|
||||||
|
// inside the page's box, which is what lets this handler stay a matter of the label alone --
|
||||||
|
// and refilling it is a page *switch*, not a frame, so rebuilding it here is cheap and is
|
||||||
|
// also the only way to be sure the pictures are this page's and not the last one's.
|
||||||
|
GtkWidget* label = GTK_WIDGET(body_label);
|
||||||
|
GtkWidget* parent = gtk_widget_get_parent(label);
|
||||||
|
GtkWidget* shots = parent != nullptr ? gtk_widget_get_first_child(parent) : nullptr;
|
||||||
|
if (shots != nullptr && GTK_IS_BOX(shots)) {
|
||||||
|
while (GtkWidget* old = gtk_widget_get_first_child(shots)) {
|
||||||
|
gtk_box_remove(GTK_BOX(shots), old);
|
||||||
|
}
|
||||||
|
for (const char* const* img = kPages[idx].images; img != nullptr && *img != nullptr; ++img) {
|
||||||
|
GtkWidget* shot = gtk_picture_new_for_resource(*img);
|
||||||
|
gtk_picture_set_can_shrink(GTK_PICTURE(shot), TRUE);
|
||||||
|
// A height, and `CONTAIN` (the default) to fit the image inside it: a picture that may
|
||||||
|
// shrink asks for no height at all in a box, so without this it is allocated nothing and
|
||||||
|
// the page shows its text with a gap where the screenshots should be.
|
||||||
|
gtk_widget_set_size_request(shot, -1, kHelpShotHeight);
|
||||||
|
gtk_box_append(GTK_BOX(shots), shot);
|
||||||
|
}
|
||||||
|
gtk_widget_set_visible(shots, gtk_widget_get_first_child(shots) != nullptr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void show_about(GtkWindow* parent, const std::string& session_path) {
|
||||||
|
if (g_about != nullptr) {
|
||||||
|
adw_dialog_present(g_about, GTK_WIDGET(parent));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `AdwAboutDialog` rather than `GtkAboutDialog`: it shows the application icon as the page's
|
||||||
|
// own header, which is the whole reason the app has one, and `adw_about_window_new()` is
|
||||||
|
// deprecated in favour of it. It is an `AdwDialog`, so it is modal on what it is presented
|
||||||
|
// on and needs no transient-for or modal call of its own.
|
||||||
|
AdwDialog* dialog = adw_about_dialog_new();
|
||||||
|
g_about = dialog;
|
||||||
|
AdwAboutDialog* about = ADW_ABOUT_DIALOG(dialog);
|
||||||
|
adw_about_dialog_set_application_name(about, "udpscope");
|
||||||
|
adw_about_dialog_set_application_icon(about, "org.udpscope.app");
|
||||||
|
adw_about_dialog_set_developer_name(about, "Martino Ferrari");
|
||||||
|
adw_about_dialog_set_version(about, build_info::kVersion);
|
||||||
|
adw_about_dialog_set_comments(about, "An oscilloscope for UDPStreamer sources.");
|
||||||
|
adw_about_dialog_set_license_type(about, GTK_LICENSE_MIT_X11);
|
||||||
|
adw_about_dialog_set_website(about, "https://git.ferrarinas.it/martino/udpscope");
|
||||||
|
|
||||||
|
// `debug_info` rather than `system_information`: it renders as a Troubleshooting page with a
|
||||||
|
// copy button, where the old field was inert text a user had to retype. A `std::string`
|
||||||
|
// rather than a fixed buffer, because a long settings path used to truncate it silently.
|
||||||
|
const std::string debug = "Commit: " + std::string(build_info::kCommit) +
|
||||||
|
"\nBuilt: " + build_info::kTimestamp +
|
||||||
|
"\nSettings: " + config_dir() +
|
||||||
|
"\nSession file: " + session_path;
|
||||||
|
adw_about_dialog_set_debug_info(about, debug.c_str());
|
||||||
|
|
||||||
|
g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_about);
|
||||||
|
adw_dialog_present(dialog, GTK_WIDGET(parent));
|
||||||
|
}
|
||||||
|
|
||||||
|
const char* about_icon_name() {
|
||||||
|
if (g_about == nullptr) return "";
|
||||||
|
return adw_about_dialog_get_application_icon(ADW_ABOUT_DIALOG(g_about));
|
||||||
|
}
|
||||||
|
|
||||||
|
HelpReport help_self_check() {
|
||||||
|
HelpReport report;
|
||||||
|
report.pages = kPageCount;
|
||||||
|
for (const HelpPage& page : kPages) {
|
||||||
|
bool ok = false;
|
||||||
|
const std::string body = load_page(page, &ok);
|
||||||
|
if (ok && !body.empty()) ++report.parsed;
|
||||||
|
if (report.shortest_title.empty() || body.size() < report.shortest_bytes) {
|
||||||
|
report.shortest_title = page.title;
|
||||||
|
report.shortest_bytes = body.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
void show_help(GtkWindow* parent) {
|
||||||
|
if (g_help != nullptr) {
|
||||||
|
gtk_window_present(GTK_WINDOW(g_help));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* window = gtk_window_new();
|
||||||
|
g_help = window;
|
||||||
|
gtk_window_set_title(GTK_WINDOW(window), "udpscope help");
|
||||||
|
gtk_window_set_default_size(GTK_WINDOW(window), 860, 640);
|
||||||
|
gtk_window_set_transient_for(GTK_WINDOW(window), parent);
|
||||||
|
|
||||||
|
// One label, width-locked by the scrolled window, holding one page's markup. The
|
||||||
|
// sidebar swaps the markup rather than rebuilding anything.
|
||||||
|
GtkWidget* body = gtk_label_new(nullptr);
|
||||||
|
gtk_label_set_use_markup(GTK_LABEL(body), TRUE);
|
||||||
|
gtk_label_set_wrap(GTK_LABEL(body), TRUE);
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(body), 0.0f);
|
||||||
|
gtk_label_set_yalign(GTK_LABEL(body), 0.0f);
|
||||||
|
gtk_label_set_selectable(GTK_LABEL(body), TRUE);
|
||||||
|
gtk_widget_set_margin_start(body, 18);
|
||||||
|
gtk_widget_set_margin_end(body, 18);
|
||||||
|
gtk_widget_set_margin_top(body, 14);
|
||||||
|
gtk_widget_set_margin_bottom(body, 14);
|
||||||
|
|
||||||
|
GtkWidget* body_scroll = gtk_scrolled_window_new();
|
||||||
|
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(body_scroll), GTK_POLICY_NEVER,
|
||||||
|
GTK_POLICY_AUTOMATIC);
|
||||||
|
// The page's own column: its screenshots above its text. Built once and *refilled* by the page
|
||||||
|
// switch, which is why no picture is made here -- one `GtkPicture` per image of the page.
|
||||||
|
GtkWidget* shot_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||||
|
gtk_widget_set_margin_start(shot_box, 18);
|
||||||
|
gtk_widget_set_margin_end(shot_box, 18);
|
||||||
|
gtk_widget_set_margin_top(shot_box, 14);
|
||||||
|
gtk_widget_set_visible(shot_box, false);
|
||||||
|
GtkWidget* page_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
|
gtk_box_append(GTK_BOX(page_box), shot_box);
|
||||||
|
gtk_box_append(GTK_BOX(page_box), body);
|
||||||
|
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(body_scroll), page_box);
|
||||||
|
gtk_widget_set_hexpand(body_scroll, TRUE);
|
||||||
|
|
||||||
|
GtkWidget* list = gtk_list_box_new();
|
||||||
|
gtk_list_box_set_selection_mode(GTK_LIST_BOX(list), GTK_SELECTION_SINGLE);
|
||||||
|
const char* current_section = nullptr;
|
||||||
|
for (int i = 0; i < kPageCount; ++i) {
|
||||||
|
if (current_section == nullptr ||
|
||||||
|
std::string(current_section) != kPages[i].section) {
|
||||||
|
current_section = kPages[i].section;
|
||||||
|
GtkWidget* heading = gtk_label_new(nullptr);
|
||||||
|
char markup[128];
|
||||||
|
std::snprintf(markup, sizeof(markup), "<b><small>%s</small></b>", current_section);
|
||||||
|
gtk_label_set_markup(GTK_LABEL(heading), markup);
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
|
||||||
|
gtk_widget_set_margin_start(heading, 10);
|
||||||
|
gtk_widget_set_margin_top(heading, 10);
|
||||||
|
gtk_widget_set_margin_bottom(heading, 2);
|
||||||
|
GtkWidget* heading_row = gtk_list_box_row_new();
|
||||||
|
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(heading_row), FALSE);
|
||||||
|
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(heading_row), FALSE);
|
||||||
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(heading_row), heading);
|
||||||
|
gtk_list_box_append(GTK_LIST_BOX(list), heading_row);
|
||||||
|
}
|
||||||
|
GtkWidget* label = gtk_label_new(kPages[i].title);
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
|
||||||
|
gtk_widget_set_margin_start(label, 18);
|
||||||
|
gtk_widget_set_margin_end(label, 10);
|
||||||
|
gtk_widget_set_margin_top(label, 4);
|
||||||
|
gtk_widget_set_margin_bottom(label, 4);
|
||||||
|
GtkWidget* row = gtk_list_box_row_new();
|
||||||
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
|
||||||
|
g_object_set_data(G_OBJECT(row), "help-page", GINT_TO_POINTER(i));
|
||||||
|
gtk_list_box_append(GTK_LIST_BOX(list), row);
|
||||||
|
}
|
||||||
|
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), body);
|
||||||
|
|
||||||
|
GtkWidget* list_scroll = gtk_scrolled_window_new();
|
||||||
|
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(list_scroll), GTK_POLICY_NEVER,
|
||||||
|
GTK_POLICY_AUTOMATIC);
|
||||||
|
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(list_scroll), list);
|
||||||
|
gtk_widget_set_size_request(list_scroll, 220, -1);
|
||||||
|
|
||||||
|
GtkWidget* split = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 0);
|
||||||
|
gtk_box_append(GTK_BOX(split), list_scroll);
|
||||||
|
gtk_box_append(GTK_BOX(split), gtk_separator_new(GTK_ORIENTATION_VERTICAL));
|
||||||
|
gtk_box_append(GTK_BOX(split), body_scroll);
|
||||||
|
gtk_window_set_child(GTK_WINDOW(window), split);
|
||||||
|
|
||||||
|
// Select the first page so the window never opens blank.
|
||||||
|
GtkListBoxRow* first = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 1);
|
||||||
|
if (first != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(list), first);
|
||||||
|
|
||||||
|
g_signal_connect(window, "close-request", G_CALLBACK(on_window_close), &g_help);
|
||||||
|
gtk_window_present(GTK_WINDOW(window));
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// Appends one table row to `dialog`, opening a new section whenever the row's `section`
|
||||||
|
/// string differs from the last one. `*current` is the section being filled, `*title` the
|
||||||
|
/// name it carries; both are owned by the caller so the two tables share one run.
|
||||||
|
///
|
||||||
|
/// `accel` is handed to `AdwShortcutsItem` as an accelerator when it is non-empty and
|
||||||
|
/// `subtitle` is used when it is not, which is how a wheel or a drag gets a row: neither
|
||||||
|
/// GTK's nor libadwaita's accelerator syntax can name one, and asking them to parse
|
||||||
|
/// "Wheel" costs a parse warning and draws nothing.
|
||||||
|
void append_shortcut(AdwShortcutsDialog* dialog, AdwShortcutsSection** current,
|
||||||
|
const char** title, const char* section, const char* accel,
|
||||||
|
const char* subtitle, const char* description) {
|
||||||
|
if (*current == nullptr || std::string(*title) != section) {
|
||||||
|
*current = adw_shortcuts_section_new(section);
|
||||||
|
*title = section;
|
||||||
|
adw_shortcuts_dialog_add(dialog, *current);
|
||||||
|
}
|
||||||
|
AdwShortcutsItem* item = adw_shortcuts_item_new(description, accel);
|
||||||
|
if (subtitle != nullptr && *subtitle != '\0') {
|
||||||
|
adw_shortcuts_item_set_subtitle(item, subtitle);
|
||||||
|
}
|
||||||
|
adw_shortcuts_section_add(*current, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void show_shortcuts(GtkWindow* parent) {
|
||||||
|
if (g_shortcuts != nullptr) {
|
||||||
|
adw_dialog_present(g_shortcuts, GTK_WIDGET(parent));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AdwDialog* dialog = adw_shortcuts_dialog_new();
|
||||||
|
g_shortcuts = dialog;
|
||||||
|
adw_dialog_set_title(dialog, "Keyboard and mouse shortcuts");
|
||||||
|
|
||||||
|
AdwShortcutsSection* section = nullptr;
|
||||||
|
const char* section_title = nullptr;
|
||||||
|
int accel_count = 0;
|
||||||
|
const Accel* accels = app_accels(&accel_count);
|
||||||
|
for (int i = 0; i < accel_count; ++i) {
|
||||||
|
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), §ion, §ion_title,
|
||||||
|
accels[i].section, accels[i].accel, "", accels[i].description);
|
||||||
|
}
|
||||||
|
int gesture_count = 0;
|
||||||
|
const Gesture* gestures = plot_gestures(&gesture_count);
|
||||||
|
for (int i = 0; i < gesture_count; ++i) {
|
||||||
|
append_shortcut(ADW_SHORTCUTS_DIALOG(dialog), §ion, §ion_title,
|
||||||
|
gestures[i].section, gestures[i].accel, gestures[i].subtitle,
|
||||||
|
gestures[i].description);
|
||||||
|
}
|
||||||
|
|
||||||
|
g_signal_connect(dialog, "closed", G_CALLBACK(on_dialog_closed), &g_shortcuts);
|
||||||
|
adw_dialog_present(dialog, GTK_WIDGET(parent));
|
||||||
|
}
|
||||||
|
|
||||||
|
void close_help_windows() {
|
||||||
|
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
|
||||||
|
if (g_about != nullptr) adw_dialog_close(g_about);
|
||||||
|
if (g_shortcuts != nullptr) adw_dialog_close(g_shortcuts);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// The About dialog: the app icon, version, commit, build timestamp and where the session
|
||||||
|
/// files live. An `AdwAboutDialog`, so it is modal on `parent` by being presented on it, and
|
||||||
|
/// it is an `AdwDialog` rather than a `GtkWindow`. `session_path` is this run's own session
|
||||||
|
/// file; it goes on the Troubleshooting page, which has a copy button.
|
||||||
|
void show_about(GtkWindow* parent, const std::string& session_path);
|
||||||
|
|
||||||
|
/// The icon name the open About dialog carries, or "" when it is not open. The probe reads it
|
||||||
|
/// so the dialog is asserted to *show* the app icon rather than merely to open.
|
||||||
|
const char* about_icon_name();
|
||||||
|
|
||||||
|
/// The paged help window. Modal on nothing — it is a reference, so it stays usable while
|
||||||
|
/// the scope runs. Already open: presented.
|
||||||
|
void show_help(GtkWindow* parent);
|
||||||
|
|
||||||
|
/// What UDPSCOPE_PROBE_HELP_MS reports. `parsed` counts pages whose markup
|
||||||
|
/// pango_parse_markup() accepted and whose body is non-empty; it must equal `pages`.
|
||||||
|
struct HelpReport {
|
||||||
|
int pages = 0;
|
||||||
|
int parsed = 0;
|
||||||
|
std::string shortest_title;
|
||||||
|
std::size_t shortest_bytes = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Loads and validates every page without building a window. Safe to call headless.
|
||||||
|
HelpReport help_self_check();
|
||||||
|
|
||||||
|
/// The shortcut overview: the application accelerators and the plot's own interactions,
|
||||||
|
/// in the sections the tables name. An AdwShortcutsDialog, because GtkShortcutsWindow is
|
||||||
|
/// deprecated as of GTK 4.18 and this project builds warning-free.
|
||||||
|
void show_shortcuts(GtkWindow* parent);
|
||||||
|
|
||||||
|
/// Closes whichever of the three windows are open. One of them is a GtkWindow -- the help
|
||||||
|
/// window, closed with gtk_window_close(), which emits close-request so its handler runs. The
|
||||||
|
/// other two are AdwDialogs, which are not GtkWindows: they close with adw_dialog_close(),
|
||||||
|
/// which emits ::closed. Either way the file-scope pointer is nulled by the handler, which is
|
||||||
|
/// what prevents a second open presenting a dead widget. Used by the probe.
|
||||||
|
void close_help_windows();
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The symbolic form of the app icon: the same metaphor reduced to a screen frame with one
|
||||||
|
wave inside it, following the UI-icon rules rather than the app-icon ones.
|
||||||
|
|
||||||
|
Filled paths only, with no stroke anywhere. GTK's symbolic recolour wraps the file in a
|
||||||
|
stylesheet that reaches `fill` on `path`, `rect` and `circle` only, so a stroked line keeps
|
||||||
|
the colour written here and stays dark on a dark theme. The wave is therefore a closed
|
||||||
|
2px band: a sine along its top edge and the same sine 2px lower along its bottom, which is
|
||||||
|
how a stroke's look is had from a fill.
|
||||||
|
|
||||||
|
`fill-rule="evenodd"` over three subpaths. The frame is the outer rect minus the inner one,
|
||||||
|
so the screen's interior is two crossings deep and empty; the wave inside it is three, which
|
||||||
|
is odd, so it fills. Whole-pixel coordinates throughout, because this is drawn at 16px far
|
||||||
|
more often than not and a half-pixel feature is a grey smear at 1x.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||||
|
<path fill="#2e3436" fill-rule="evenodd"
|
||||||
|
d="M 1 2 H 15 V 14 H 1 Z
|
||||||
|
M 2 3 H 14 V 13 H 2 Z
|
||||||
|
M 3 7 C 5 3 6 3 8 7 C 10 11 11 11 13 7 L 13 9 C 11 13 10 13 8 9 C 6 5 5 5 3 9 Z"/>
|
||||||
|
</svg>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
|
<!--
|
||||||
|
The application icon: an oscilloscope's face, which is the clearest metaphor available and
|
||||||
|
the kind of physical object the HIG's top-and-front depth language was written for.
|
||||||
|
|
||||||
|
Drawn to <https://developer.gnome.org/hig/guidelines/app-icons.html>. The artwork spans
|
||||||
|
x 12..116 and y 22..106, so it does not fill the 128px canvas, and every feature sits on
|
||||||
|
the 2px grid. Depth is the 4px front profile along the bottom and nothing else: that is
|
||||||
|
the HIG's maximum of 2 detail units, and since every surface here is straight, the rest
|
||||||
|
is flat colour with no gradients and no shadows.
|
||||||
|
|
||||||
|
The profile is the bottom 4px of the chassis silhouette, clipped to it. A plain rect from
|
||||||
|
(12, 102) to (116, 106) would protrude past the radius-8 corners, which reads as a
|
||||||
|
misdrawn bump at 128px and as a dirty edge at 32px; the clip follows the corners exactly
|
||||||
|
and keeps every coordinate even, which hand-computing the arc would not.
|
||||||
|
|
||||||
|
Two deliberate omissions. There is no graticule: the grid lines a real scope face carries
|
||||||
|
cannot survive 32px, and the trace carries the metaphor without them. And the trace is
|
||||||
|
one cycle, a single crest and a single trough, because at 32px the screen is about
|
||||||
|
22x16 px and anything busier reads as a smudge.
|
||||||
|
|
||||||
|
Colours are the GNOME palette: Dark 3 chassis, Dark 2 profile, Dark 4 screen, Green 1 trace.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||||
|
<defs>
|
||||||
|
<clipPath id="chassis-silhouette">
|
||||||
|
<rect x="12" y="22" width="104" height="84" rx="8"/>
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect x="12" y="22" width="104" height="84" rx="8" fill="#3d3846"/>
|
||||||
|
<g clip-path="url(#chassis-silhouette)">
|
||||||
|
<rect x="12" y="102" width="104" height="4" fill="#5e5c64"/>
|
||||||
|
</g>
|
||||||
|
<rect x="20" y="30" width="88" height="64" rx="4" fill="#241f31"/>
|
||||||
|
<path d="M 24 62 C 37 38 51 38 64 62 C 77 86 91 86 104 62"
|
||||||
|
fill="none" stroke="#8ff0a4" stroke-width="6" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
@@ -2,16 +2,26 @@
|
|||||||
<!-- SPDX-License-Identifier: MIT -->
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
<!--
|
<!--
|
||||||
The four icons this app had to draw for itself, compiled into the binary so there is
|
The icons this app had to draw for itself, compiled into the binary so there is still
|
||||||
still exactly one file to run. `gtk_icon_theme_add_resource_path()` wants the tree an
|
exactly one file to run. `gtk_icon_theme_add_resource_path()` wants the tree an icon theme
|
||||||
icon theme has, so the prefix is followed by `<size>/<context>`; `scalable` is the one
|
has, so the prefix is followed by `<size>/<context>`; `scalable` is the one that means
|
||||||
that means "an SVG, use it at any size".
|
"an SVG, use it at any size", and `apps` is where an application icon belongs.
|
||||||
|
|
||||||
|
Every file carries `preprocess="xml-stripblanks"`, which makes glib-compile-resources pipe
|
||||||
|
it through xmllint. That is the only thing that catches a malformed SVG: librsvg refuses
|
||||||
|
such a file outright, but the icon *name* still resolves, so `gtk_icon_theme_has_icon()`
|
||||||
|
returns true and the icon simply draws nothing. No runtime GTK call can tell the two apart
|
||||||
|
(`lookup_icon()` never returns NULL and always reports the size it was asked for), so the
|
||||||
|
check belongs here, where a malformed SVG fails the build with a file and line number
|
||||||
|
(plus a caret under the offending character) instead.
|
||||||
-->
|
-->
|
||||||
<gresources>
|
<gresources>
|
||||||
<gresource prefix="/org/udpscope/icons">
|
<gresource prefix="/org/udpscope/icons">
|
||||||
<file>scalable/actions/udpscope-trigger-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-trigger-symbolic.svg</file>
|
||||||
<file>scalable/actions/udpscope-cursors-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-cursors-symbolic.svg</file>
|
||||||
<file>scalable/actions/udpscope-measure-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-measure-symbolic.svg</file>
|
||||||
<file>scalable/actions/udpscope-math-symbolic.svg</file>
|
<file preprocess="xml-stripblanks">scalable/actions/udpscope-math-symbolic.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app.svg</file>
|
||||||
|
<file preprocess="xml-stripblanks">scalable/apps/org.udpscope.app-symbolic.svg</file>
|
||||||
</gresource>
|
</gresource>
|
||||||
</gresources>
|
</gresources>
|
||||||
@@ -31,14 +31,6 @@ std::string sanitize(const std::string& host) {
|
|||||||
return out.empty() ? "_" : out;
|
return out.empty() ? "_" : out;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string config_dir() {
|
|
||||||
const char* xdg = std::getenv("XDG_CONFIG_HOME");
|
|
||||||
if (xdg != nullptr && xdg[0] != '\0') return std::string(xdg) + "/udpscope";
|
|
||||||
const char* home = std::getenv("HOME");
|
|
||||||
if (home == nullptr || home[0] == '\0') return "/tmp/udpscope-config";
|
|
||||||
return std::string(home) + "/.config/udpscope";
|
|
||||||
}
|
|
||||||
|
|
||||||
void mkdirs(const std::string& path) {
|
void mkdirs(const std::string& path) {
|
||||||
// **Every** component, the last one included. The condition used to be "the path has a trailing
|
// **Every** component, the last one included. The condition used to be "the path has a trailing
|
||||||
// slash", so the final directory was never created: `~/.config/udpscope` did not exist, the
|
// slash", so the final directory was never created: `~/.config/udpscope` did not exist, the
|
||||||
@@ -114,6 +106,14 @@ bool parse_string_array(const std::string& in, std::vector<std::string>& out) {
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
std::string config_dir() {
|
||||||
|
const char* xdg = std::getenv("XDG_CONFIG_HOME");
|
||||||
|
if (xdg != nullptr && xdg[0] != '\0') return std::string(xdg) + "/udpscope";
|
||||||
|
const char* home = std::getenv("HOME");
|
||||||
|
if (home == nullptr || home[0] == '\0') return "/tmp/udpscope-config";
|
||||||
|
return std::string(home) + "/.config/udpscope";
|
||||||
|
}
|
||||||
|
|
||||||
std::string layout_path(const std::string& host, std::uint16_t port) {
|
std::string layout_path(const std::string& host, std::uint16_t port) {
|
||||||
const std::string dir = config_dir();
|
const std::string dir = config_dir();
|
||||||
mkdirs(dir);
|
mkdirs(dir);
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ bool forget_source(const KnownSource& src);
|
|||||||
std::string math_node_text(const MathNode& node);
|
std::string math_node_text(const MathNode& node);
|
||||||
bool math_node_from_text(const std::string& text, MathNode& out);
|
bool math_node_from_text(const std::string& text, MathNode& out);
|
||||||
|
|
||||||
|
/// The directory session files live in: $XDG_CONFIG_HOME/udpscope, else
|
||||||
|
/// ~/.config/udpscope, else /tmp/udpscope-config. Not created by this call.
|
||||||
|
std::string config_dir();
|
||||||
|
|
||||||
/// `~/.config/udpscope/<host>_<port>.toml`, with the directory created.
|
/// `~/.config/udpscope/<host>_<port>.toml`, with the directory created.
|
||||||
/// Characters unsafe in a filename are replaced in the host part.
|
/// Characters unsafe in a filename are replaced in the host part.
|
||||||
std::string layout_path(const std::string& host, std::uint16_t port);
|
std::string layout_path(const std::string& host, std::uint16_t port);
|
||||||
|
|||||||
+63
-15
@@ -28,8 +28,27 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
|
|||||||
acc.open[e] = true;
|
acc.open[e] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The hysteresis band's half-width, as a share of the window's peak-to-peak. Measured rather than
|
||||||
|
/// chosen: 5 % reads the mock's 0.1 interferer correctly and fails at 0.2 (`period=0.075` for a
|
||||||
|
/// true 0.2), while 10 % holds to an interferer amplitude of 0.3 -- a third of the waveform -- and
|
||||||
|
/// to white noise at sigma=0.05. Above that the band starts eating a real waveform's own extremes
|
||||||
|
/// on anything less square than a sine.
|
||||||
|
constexpr double kHysteresisFrac = 0.10;
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
|
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct) {
|
||||||
|
const double span = s.hi - s.lo;
|
||||||
|
acc.level = s.lo + pct * span;
|
||||||
|
// Half the room to the nearer extreme is the clamp. A threshold at 90 % has a tenth of the span
|
||||||
|
// above it, so a fixed 10 % band would put `level + h` *above every sample*: the walk then never
|
||||||
|
// decides the upper side and the measurement reads nothing at all, which is strictly worse than
|
||||||
|
// a noisy reading. Measured on a 3.3 V logic signal with ringing at thr=90 %: `enters=0` with a
|
||||||
|
// fixed band, `enters=4 period=0.200000` with the clamp.
|
||||||
|
const double room = std::min(s.hi - acc.level, acc.level - s.lo);
|
||||||
|
acc.hysteresis = std::max(0.0, std::min(kHysteresisFrac * span, 0.5 * room));
|
||||||
|
}
|
||||||
|
|
||||||
void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
|
void measure_scan(const MeasureChunk& chunk, MeasureStats& s) {
|
||||||
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
|
// The chunk's own extremes first, in a loop that touches nothing but the values: *where* an
|
||||||
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
|
// extreme is costs an index, and the caller turns the two that survive into stamps once at the
|
||||||
@@ -130,17 +149,27 @@ void measure_accumulate(const MeasureChunk& chunk,
|
|||||||
for (std::size_t i = 0; i < chunk.count; ++i) {
|
for (std::size_t i = 0; i < chunk.count; ++i) {
|
||||||
const double v = static_cast<double>(chunk.value[i]);
|
const double v = static_cast<double>(chunk.value[i]);
|
||||||
if (!std::isfinite(v)) continue;
|
if (!std::isfinite(v)) continue;
|
||||||
|
// The *sums* split at the bare level: `Top` and `Bottom` are defined as the mean above and
|
||||||
|
// below the threshold, and a band here would move a documented exact reading.
|
||||||
const int s = v > acc.level ? 1 : 0;
|
const int s = v > acc.level ? 1 : 0;
|
||||||
acc.sum[static_cast<std::size_t>(s)] += v;
|
acc.sum[static_cast<std::size_t>(s)] += v;
|
||||||
++acc.count[static_cast<std::size_t>(s)];
|
++acc.count[static_cast<std::size_t>(s)];
|
||||||
// A sample *at* the level is on neither side, so it decides no crossing and the walk keeps
|
// The *edge* decision reads the band instead. A sample inside it is on neither side, so it
|
||||||
// the side it last decided. That is what stops a signal which merely touches the level from
|
// decides no crossing and the walk keeps the side it last decided -- which is what stops a
|
||||||
// counting every sample as an edge.
|
// signal that merely wobbles about the level from counting every wobble as an edge. With
|
||||||
if (v == acc.level) continue;
|
// `hysteresis == 0` this is the single-level test, `v == level` skip included.
|
||||||
if (acc.have_side && s != (acc.side ? 1 : 0)) {
|
int d;
|
||||||
enter_side(acc, static_cast<std::size_t>(s), time_of(chunk.offset + i));
|
if (v > acc.level + acc.hysteresis) {
|
||||||
|
d = 1;
|
||||||
|
} else if (v < acc.level - acc.hysteresis) {
|
||||||
|
d = 0;
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
acc.side = s != 0;
|
if (acc.have_side && d != (acc.side ? 1 : 0)) {
|
||||||
|
enter_side(acc, static_cast<std::size_t>(d), time_of(chunk.offset + i));
|
||||||
|
}
|
||||||
|
acc.side = d != 0;
|
||||||
acc.have_side = true;
|
acc.have_side = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,21 +182,40 @@ bool measure_accumulate_bucket(const MeasureBucket& k,
|
|||||||
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
|
// A non-finite sample in the bucket would make the edge's position a guess: the raw walk takes
|
||||||
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
|
// the first *finite* sample of the new side, and an aggregate cannot say which that is.
|
||||||
if (k.count != k.span) return false;
|
if (k.count != k.span) return false;
|
||||||
// Strictly on one side, so every sample decides the same side and none is *at* the level --
|
// The *sums* need only the bare level: strictly on one side of it, so every sample decides the
|
||||||
// which is what makes this exact rather than an approximation of the walk.
|
// same bin and none is *at* it, which is what makes the fold exact rather than an approximation.
|
||||||
const bool above = k.lo > acc.level;
|
const bool above = k.lo > acc.level;
|
||||||
const bool below = k.hi < acc.level;
|
const bool below = k.hi < acc.level;
|
||||||
if (!above && !below) return false;
|
if (!above && !below) return false;
|
||||||
const std::size_t e = above ? 1u : 0u;
|
const std::size_t e = above ? 1u : 0u;
|
||||||
|
// The band is deliberately *not* folded into the test above. Tightening the whole condition to
|
||||||
|
// `level +/- h` refuses every bucket lying inside the band -- a tenth of the amplitude either side
|
||||||
|
// of the mid level is where a waveform moves fastest, so those are exactly the buckets a crossing
|
||||||
|
// passes through -- which hands the raw walk a share of the window rather than a bucket or two per
|
||||||
|
// crossing, and breaks the "`raw` stays at a fraction of a percent of `covered`" invariant.
|
||||||
|
// Instead: a bucket on one side of the level can always be *binned*, and only a *side change*
|
||||||
|
// whose position is inside the bucket is refused.
|
||||||
|
const double top = acc.level + acc.hysteresis;
|
||||||
|
const double bot = acc.level - acc.hysteresis;
|
||||||
|
// Does the bucket reach past the band at all? One wholly inside it decides no side, exactly as a
|
||||||
|
// raw sample inside it decides none, so the walk state is left alone.
|
||||||
|
const bool reaches = above ? (k.hi > top) : (k.lo < bot);
|
||||||
|
const bool change = reaches && acc.have_side && e != (acc.side ? 1u : 0u);
|
||||||
|
// A bucket that *enters* a side while straddling its own threshold holds the crossing somewhere
|
||||||
|
// inside itself, and only the raw walk can say where. One wholly past the threshold holds it at
|
||||||
|
// the bucket's first sample, exactly, which is where the raw walk would have found it.
|
||||||
|
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
|
||||||
|
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
|
||||||
|
// which adds the same samples again. Sums added above the refusal double-counted every opened
|
||||||
|
// bucket, which read as `top` 0.614304 on a unit sine against the 2/pi it must be.
|
||||||
|
if (change && !fully_outside) return false;
|
||||||
acc.sum[e] += k.sum;
|
acc.sum[e] += k.sum;
|
||||||
acc.count[e] += k.count;
|
acc.count[e] += k.count;
|
||||||
if (acc.have_side && e != (acc.side ? 1u : 0u)) {
|
if (change) enter_side(acc, e, time_of(k.offset));
|
||||||
// The edge is the bucket's *first* sample: everything before it was on the other side and
|
if (reaches) {
|
||||||
// everything in the bucket is on this one, so that is where the raw walk would have found it.
|
acc.side = e != 0;
|
||||||
enter_side(acc, e, time_of(k.offset));
|
acc.have_side = true;
|
||||||
}
|
}
|
||||||
acc.side = e != 0;
|
|
||||||
acc.have_side = true;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-4
@@ -152,6 +152,20 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu
|
|||||||
/// must adding a second measurement of the same type at the same level.
|
/// must adding a second measurement of the same type at the same level.
|
||||||
struct MeasureLevel {
|
struct MeasureLevel {
|
||||||
double level = 0.0;
|
double level = 0.0;
|
||||||
|
/// Half-width of the hysteresis band about `level`: a crossing is counted only once the signal
|
||||||
|
/// has passed `level + hysteresis` going up or `level - hysteresis` going down, and a sample
|
||||||
|
/// inside the band decides nothing. It is a Schmitt trigger, and it is what makes an edge count
|
||||||
|
/// a count of the *waveform's* cycles rather than of its noise: a single level is recrossed
|
||||||
|
/// several times per true crossing by any interferer faster than the fundamental, because near
|
||||||
|
/// the mid level the fundamental's own slope is at its smallest. Measured on a 5 Hz sine with a
|
||||||
|
/// 500 Hz interferer at a tenth of the amplitude: 9 true crossings counted as 79, reading
|
||||||
|
/// `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz. With the band, `period=0.200188`.
|
||||||
|
///
|
||||||
|
/// It moves the *edge decision* only. The `sum`/`count` split below stays at the bare `level`,
|
||||||
|
/// because that is what `Top`/`Bottom` are -- the mean of the samples above/below the threshold --
|
||||||
|
/// and a band there would change a documented exact reading (`top` on a unit sine is 2/pi).
|
||||||
|
/// `measure_set_level()` is what sizes it; zero reduces the walk exactly to a single level.
|
||||||
|
double hysteresis = 0.0;
|
||||||
/// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what
|
/// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what
|
||||||
/// `bottom` has always reported), index 1 the side above it.
|
/// `bottom` has always reported), index 1 the side above it.
|
||||||
double sum[2] = {0.0, 0.0};
|
double sum[2] = {0.0, 0.0};
|
||||||
@@ -170,6 +184,14 @@ struct MeasureLevel {
|
|||||||
bool open[2] = {false, false};
|
bool open[2] = {false, false};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Fill a level accumulator's `level` and `hysteresis` from the window's extremes and the share
|
||||||
|
/// `measure_level_pct()` gave. The one place the band is sized, so every caller gets the same rule:
|
||||||
|
/// a tenth of the window's peak-to-peak, *clamped* to half the room between the level and the
|
||||||
|
/// nearer extreme. The clamp is what keeps a threshold near an extreme usable -- `PulseDuration`
|
||||||
|
/// lets the user put one at 90 %, and a fixed band there puts the upper threshold above every
|
||||||
|
/// sample in the window, which reads as nothing measured at all rather than as a noisy reading.
|
||||||
|
void measure_set_level(MeasureLevel& level, const MeasureStats& stats, double pct);
|
||||||
|
|
||||||
/// Pass two: one chunk into every level at once. The usual case is a single level -- every
|
/// Pass two: one chunk into every level at once. The usual case is a single level -- every
|
||||||
/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of
|
/// crossing type reads the mid level and `Top`/`Bottom` default to it too -- so a pane full of
|
||||||
/// measurements on one signal costs two walks, not two per measurement.
|
/// measurements on one signal costs two walks, not two per measurement.
|
||||||
@@ -182,10 +204,14 @@ void measure_accumulate(const MeasureChunk& chunk,
|
|||||||
/// fold in whole and the walk's side is decided by the bucket rather than by 64 samples. An edge
|
/// fold in whole and the walk's side is decided by the bucket rather than by 64 samples. An edge
|
||||||
/// *into* such a bucket lands on its first sample, exactly, which is why that is the stamp taken.
|
/// *into* such a bucket lands on its first sample, exactly, which is why that is the stamp taken.
|
||||||
///
|
///
|
||||||
/// False means the bucket straddles the level -- or holds a non-finite sample, which would make
|
/// False means the caller must feed the bucket's raw samples to `measure_accumulate()` for this
|
||||||
/// the edge's position a guess -- and the caller must feed its raw samples to
|
/// level instead. That is the only place a measurement still reads raw samples in bulk, and on a
|
||||||
/// `measure_accumulate()` for this level instead. That is the only place a measurement still
|
/// clean waveform it is a bucket or two per crossing. Three things make it happen: the bucket
|
||||||
/// reads raw samples in bulk, and on a clean waveform it is a bucket per crossing.
|
/// straddles the level (so the sums cannot be binned); it holds a non-finite sample, which would
|
||||||
|
/// make the edge's position a guess; or it is where the signal passes `level +/- hysteresis` and the
|
||||||
|
/// *position* of that crossing is inside the bucket. The band is deliberately not folded into the
|
||||||
|
/// first test -- see the comment in the implementation for why refusing every bucket inside the band
|
||||||
|
/// costs a share of the window rather than a bucket.
|
||||||
bool measure_accumulate_bucket(const MeasureBucket& bucket,
|
bool measure_accumulate_bucket(const MeasureBucket& bucket,
|
||||||
const std::function<double(std::size_t)>& time_of,
|
const std::function<double(std::size_t)>& time_of,
|
||||||
MeasureLevel& level);
|
MeasureLevel& level);
|
||||||
|
|||||||
+69
-33
@@ -42,12 +42,6 @@ constexpr std::uint64_t kMathCostSample = 1024;
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
u64 Partition::total_samples() const noexcept {
|
|
||||||
u64 total = time_capacity;
|
|
||||||
for (u64 c : value_capacity) total += c;
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
Partition plan_partition(const std::vector<double>& channel_dt, double buffer_bytes,
|
||||||
double target_span_s) {
|
double target_span_s) {
|
||||||
Partition p;
|
Partition p;
|
||||||
@@ -76,11 +70,14 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
|||||||
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
|
for (std::size_t i = 0; i < n; ++i) p.stride[i] = 1;
|
||||||
|
|
||||||
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
|
const u64 budget_bytes = static_cast<u64>(std::max(1.0, buffer_bytes));
|
||||||
const u64 budget = budget_bytes / sizeof(f32);
|
// Weighted in *bytes*, not in slots: the shared clock is f64 and the values are f32
|
||||||
double weight = 1.0; // the time slice itself
|
// (see `BufferStore::time()`), so a time slot costs twice a value sample and the two
|
||||||
for (u64 s : p.stride) weight += 1.0 / static_cast<double>(s);
|
// slices are not interchangeable. Counting both as one f32 is what would make
|
||||||
|
// `--buffer-mb` quietly understate the time ring by its own size.
|
||||||
|
double weight = static_cast<double>(sizeof(f64)); // the time slice itself
|
||||||
|
for (u64 s : p.stride) weight += static_cast<double>(sizeof(f32)) / static_cast<double>(s);
|
||||||
|
|
||||||
const u64 slots = budget / static_cast<u64>(std::llround(weight));
|
const u64 slots = static_cast<u64>(static_cast<double>(budget_bytes) / weight);
|
||||||
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
|
u64 time_capacity = pow2_floor(std::max<u64>(slots, 128));
|
||||||
if (time_capacity == 0) {
|
if (time_capacity == 0) {
|
||||||
throw std::runtime_error("buffer too small for one time-base sample per channel");
|
throw std::runtime_error("buffer too small for one time-base sample per channel");
|
||||||
@@ -102,11 +99,13 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
|||||||
return std::max<u64>(pow2_floor(ticks / stride), 1);
|
return std::max<u64>(pow2_floor(ticks / stride), 1);
|
||||||
};
|
};
|
||||||
const auto bytes_for = [&](u64 ticks) {
|
const auto bytes_for = [&](u64 ticks) {
|
||||||
u64 total = ticks;
|
u64 total = ticks * sizeof(f64);
|
||||||
for (u64 s : p.stride) total += capacity_for(ticks, s);
|
for (u64 s : p.stride) total += capacity_for(ticks, s) * sizeof(f32);
|
||||||
return total * sizeof(f32);
|
return total;
|
||||||
};
|
};
|
||||||
while (time_capacity * 2 <= budget && bytes_for(time_capacity * 2) <= budget_bytes) {
|
// `bytes_for` bounds the doubling on its own: it is at least `2 * ticks * sizeof(f64)`,
|
||||||
|
// so a pass means `2 * ticks` is well inside the budget and cannot overflow.
|
||||||
|
while (bytes_for(time_capacity * 2) <= budget_bytes) {
|
||||||
time_capacity *= 2;
|
time_capacity *= 2;
|
||||||
}
|
}
|
||||||
p.time_capacity = time_capacity;
|
p.time_capacity = time_capacity;
|
||||||
@@ -138,7 +137,7 @@ Partition plan_partition(const std::vector<double>& channel_dt, double buffer_by
|
|||||||
|
|
||||||
BufferStore::BufferStore(const Partition& part, f64 span_s)
|
BufferStore::BufferStore(const Partition& part, f64 span_s)
|
||||||
: part_(part),
|
: part_(part),
|
||||||
time_(std::make_shared<plot::data::RingBuffer<f32>>(part.time_capacity)) {
|
time_(std::make_shared<plot::data::RingBuffer<f64>>(part.time_capacity)) {
|
||||||
(void)span_s;
|
(void)span_s;
|
||||||
const std::size_t n = part.value_capacity.size();
|
const std::size_t n = part.value_capacity.size();
|
||||||
values_.reserve(n);
|
values_.reserve(n);
|
||||||
@@ -452,7 +451,7 @@ BufferStore& ScopeModel::live() { return *store_; }
|
|||||||
|
|
||||||
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
|
void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& last) {
|
||||||
BufferStore& src = live();
|
BufferStore& src = live();
|
||||||
plot::data::RingBuffer<f32>& tsrc = src.time();
|
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||||
|
|
||||||
// The slice is one absolute index range, found in the time ring and then shared by
|
// The slice is one absolute index range, found in the time ring and then shared by
|
||||||
// every value ring. Sharing it is what makes a cut's stamps and each of its channels
|
// every value ring. Sharing it is what makes a cut's stamps and each of its channels
|
||||||
@@ -460,16 +459,15 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
|
|||||||
// truncations that could disagree.
|
// truncations that could disagree.
|
||||||
const u64 tbase = tsrc.produced() - tsrc.size();
|
const u64 tbase = tsrc.produced() - tsrc.size();
|
||||||
const u64 tend = tsrc.produced();
|
const u64 tend = tsrc.produced();
|
||||||
// The bounds are rounded to the ring's own `f32` before they are compared against
|
// The bounds are compared in the ring's own type, which is the `f64` they arrive in,
|
||||||
// it. The window arrives in f64 and the stamps are stored as f32, so an f64
|
// so a bound that lands exactly on a sample includes it. This used to need rounding
|
||||||
// comparison decides a sample that sits exactly on a bound by which way its stamp
|
// *down* to the stored f32 first: an f64 comparison against an f32 stamp decided such
|
||||||
// happened to round -- the same nominal window then keeps or drops its first sample
|
// a sample by which way its stamp happened to round, and the same nominal window kept
|
||||||
// depending on how large the stream time is (`f32(38.001) < 38.001` but
|
// or dropped its first sample depending on the magnitude of the stream time
|
||||||
// `f32(18.001) > 18.001`). One sample is invisible at 10 kS/s and a third of the
|
// (`f32(38.001) < 38.001` but `f32(18.001) > 18.001`). The ring is f64 now and the
|
||||||
// capture at 1 S/s. Comparing in the stored type makes a bound that lands on a
|
// question does not arise.
|
||||||
// sample include it, always.
|
const f64 t_lo = lo_s;
|
||||||
const f32 t_lo = static_cast<f32>(lo_s);
|
const f64 t_hi = hi_s;
|
||||||
const f32 t_hi = static_cast<f32>(hi_s);
|
|
||||||
|
|
||||||
// Stream time is non-decreasing, so the bounds are a binary search. Searching the
|
// Stream time is non-decreasing, so the bounds are a binary search. Searching the
|
||||||
// stamps is the whole point: the window is in seconds and this ring is the only
|
// stamps is the whole point: the window is in seconds and this ring is the only
|
||||||
@@ -496,7 +494,7 @@ void ScopeModel::live_window_locked(double lo_s, double hi_s, u64& first, u64& l
|
|||||||
last = a;
|
last = a;
|
||||||
|
|
||||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||||
plot::data::RingBuffer<f32>& from = src.values(c);
|
plot::data::RingBuffer<f32>& from = src.values(c); // values stay f32
|
||||||
first = std::max(first, from.produced() - from.size());
|
first = std::max(first, from.produced() - from.size());
|
||||||
last = std::min(last, from.produced());
|
last = std::min(last, from.produced());
|
||||||
}
|
}
|
||||||
@@ -517,8 +515,12 @@ void ScopeModel::reset_live_history_locked() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
void ScopeModel::publish_pause_view(double lo_s, double hi_s, double x_offset) {
|
||||||
|
// Timed from before the lock: the wait for it is part of what freezing a view costs the thread
|
||||||
|
// doing it, and this one is the UI's.
|
||||||
|
const std::uint64_t t0 = now_ns();
|
||||||
auto snap = std::make_shared<EventSnapshot>();
|
auto snap = std::make_shared<EventSnapshot>();
|
||||||
|
snap->x_offset = x_offset;
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
BufferStore& src = live();
|
BufferStore& src = live();
|
||||||
u64 first = 0;
|
u64 first = 0;
|
||||||
@@ -527,13 +529,18 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
|||||||
const u64 n = last - first;
|
const u64 n = last - first;
|
||||||
snap->stamps.resize(static_cast<std::size_t>(n));
|
snap->stamps.resize(static_cast<std::size_t>(n));
|
||||||
if (n > 0) {
|
if (n > 0) {
|
||||||
// The stamps go in *as the ring holds them*: the stream's own seconds, which is the
|
// The stamps come out of the ring on the stream's own clock and are rebased onto
|
||||||
// coordinate the axis was already reading. Nothing is rebased, so a pause does not move the
|
// `x_offset`, which is the coordinate the axis was *already* reading: a live view is
|
||||||
// dates under the reader the way a trigger event's edge-relative axis deliberately does.
|
// newest-relative, so leaving them absolute would throw the frozen picture a whole uptime
|
||||||
|
// to the right of the axis at the instant the user pressed pause. Nothing is lost by the
|
||||||
|
// shift -- `x_offset` is exactly what makes the stream's time recoverable, which is what
|
||||||
|
// `stream_at_x0()` reads it for.
|
||||||
src.time().copy_from(first, n, snap->stamps.data());
|
src.time().copy_from(first, n, snap->stamps.data());
|
||||||
|
for (f64& t : snap->stamps) t -= x_offset;
|
||||||
}
|
}
|
||||||
snap->values.resize(channels_.size());
|
snap->values.resize(channels_.size());
|
||||||
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
snap->y.resize(channels_.size(), plot::Range{0.0, 0.0});
|
||||||
|
snap->pyramids.resize(channels_.size());
|
||||||
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
for (std::size_t c = 0; c < channels_.size(); ++c) {
|
||||||
const u64 stride = std::max<u64>(1, part_.stride[c]);
|
const u64 stride = std::max<u64>(1, part_.stride[c]);
|
||||||
auto& vals = snap->values[c];
|
auto& vals = snap->values[c];
|
||||||
@@ -542,19 +549,46 @@ void ScopeModel::publish_pause_view(double lo_s, double hi_s) {
|
|||||||
if (take > 0) src.values(c).copy_from(first + n - take, take, vals.data());
|
if (take > 0) src.values(c).copy_from(first + n - take, take, vals.data());
|
||||||
f32 lo = vals.empty() ? 0.0f : vals.front();
|
f32 lo = vals.empty() ? 0.0f : vals.front();
|
||||||
f32 hi = lo;
|
f32 hi = lo;
|
||||||
|
// The LOD index, folded into the bounds pass exactly as `publish_event()` folds it, and for
|
||||||
|
// the same reason: a snapshot that answers `pyramid()` with an empty one has `level_for()`
|
||||||
|
// descend to level 0, so the frozen view is re-gathered from every raw sample it holds on
|
||||||
|
// every frame it is on screen. A pause is not a cheaper case than a capture -- it freezes the
|
||||||
|
// *same* rings -- and leaving it out was visible twice over. The cost: `frame path:` read
|
||||||
|
// `raw=21 lod=118` over a 1.6 s pause against `raw=1 lod=170` for the same run live, with the
|
||||||
|
// draw mean 12.34 -> 18.18 ms. And the *look* changed, which is the half a cost figure does
|
||||||
|
// not show: `stage_trace_density()` grades a column against its own peak from a `1/height`
|
||||||
|
// deposit per segment, so a column fed ~660 raw inter-sample segments accumulates a far
|
||||||
|
// spikier distribution -- and a far larger peak to normalise against -- than one fed the one
|
||||||
|
// or two bucket envelopes the pyramid gives. The paused trace read as a hard bright core with
|
||||||
|
// its dwell tail dimmed out, i.e. the trace changed appearance on pause.
|
||||||
|
//
|
||||||
|
// It is not free, and the trade is one-off against per-frame: building it took the pause
|
||||||
|
// publish from 6.15 ms to 22.31 ms (`publish cost:`), which blocks the *producer* -- against
|
||||||
|
// ~5 ms a frame of draw for as long as the pause lasts, which at ~60 fps a 1.6 s pause repays
|
||||||
|
// thirty times over. The producer absorbs the stall: `gaps=0` with and without a pause, the
|
||||||
|
// paused run having published in 86 ms.
|
||||||
|
auto& pyr = snap->pyramids[c];
|
||||||
|
pyr.reserve(take);
|
||||||
for (f32 v : vals) {
|
for (f32 v : vals) {
|
||||||
lo = std::min(lo, v);
|
lo = std::min(lo, v);
|
||||||
hi = std::max(hi, v);
|
hi = std::max(hi, v);
|
||||||
|
pyr.append(v);
|
||||||
}
|
}
|
||||||
snap->y[c] = plot::Range{lo, hi};
|
snap->y[c] = plot::Range{lo, hi};
|
||||||
}
|
}
|
||||||
pause_view_.store(std::move(snap), std::memory_order_release);
|
pause_view_.store(std::move(snap), std::memory_order_release);
|
||||||
|
note_publish(publish_pause_count_, publish_pause_us_total_, publish_pause_us_worst_,
|
||||||
|
(now_ns() - t0) / 1000u);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
||||||
|
// The caller is the producer and already holds `mutex()`, so every microsecond counted here is a
|
||||||
|
// microsecond the UI tick is blocked for -- which is what the documented `tick` max is made of.
|
||||||
|
const std::uint64_t t0 = now_ns();
|
||||||
auto snap = std::make_shared<EventSnapshot>();
|
auto snap = std::make_shared<EventSnapshot>();
|
||||||
|
snap->x_offset = edge; // the stream time x = 0 means, for everything that has to cross back
|
||||||
BufferStore& src = live();
|
BufferStore& src = live();
|
||||||
plot::data::RingBuffer<f32>& tsrc = src.time();
|
plot::data::RingBuffer<f64>& tsrc = src.time();
|
||||||
|
|
||||||
u64 first = 0, last = 0;
|
u64 first = 0, last = 0;
|
||||||
live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last);
|
live_window_locked(edge - std::max(0.0, pre_s), edge + std::max(0.0, post_s), first, last);
|
||||||
@@ -566,7 +600,7 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
|||||||
// Edge-relative from here on: the frozen event reads [-pre, +post] with the
|
// Edge-relative from here on: the frozen event reads [-pre, +post] with the
|
||||||
// edge at exactly 0, with no axis-level shift any other code path could
|
// edge at exactly 0, with no axis-level shift any other code path could
|
||||||
// disagree with.
|
// disagree with.
|
||||||
for (auto& t : snap->stamps) t -= static_cast<f32>(edge);
|
for (auto& t : snap->stamps) t -= edge;
|
||||||
}
|
}
|
||||||
|
|
||||||
snap->values.resize(channels_.size());
|
snap->values.resize(channels_.size());
|
||||||
@@ -601,6 +635,8 @@ void ScopeModel::publish_event(double pre_s, double post_s, double edge) {
|
|||||||
// Bumped after the snapshot is in place, so a consumer that sees the new sequence
|
// Bumped after the snapshot is in place, so a consumer that sees the new sequence
|
||||||
// number sees the snapshot it counts.
|
// number sees the snapshot it counts.
|
||||||
event_seq_.fetch_add(1, std::memory_order_release);
|
event_seq_.fetch_add(1, std::memory_order_release);
|
||||||
|
note_publish(publish_event_count_, publish_event_us_total_, publish_event_us_worst_,
|
||||||
|
(now_ns() - t0) / 1000u);
|
||||||
}
|
}
|
||||||
|
|
||||||
void BufferStore::set_epoch(f64 epoch) noexcept {
|
void BufferStore::set_epoch(f64 epoch) noexcept {
|
||||||
|
|||||||
+88
-17
@@ -93,8 +93,6 @@ struct Partition {
|
|||||||
double base_dt = 1.0; ///< seconds per base tick
|
double base_dt = 1.0; ///< seconds per base tick
|
||||||
double span_s = 0.0; ///< stream seconds one buffer covers
|
double span_s = 0.0; ///< stream seconds one buffer covers
|
||||||
|
|
||||||
u64 total_samples() const noexcept;
|
|
||||||
u64 total_bytes() const noexcept { return total_samples() * sizeof(f32); }
|
|
||||||
/// Seconds between two time-base slots (the sample interval of every channel).
|
/// Seconds between two time-base slots (the sample interval of every channel).
|
||||||
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
|
double slot_dt() const noexcept { return base_dt * static_cast<double>(time_stride); }
|
||||||
/// The rate samples are actually stored at, in samples per second.
|
/// The rate samples are actually stored at, in samples per second.
|
||||||
@@ -125,8 +123,15 @@ class BufferStore {
|
|||||||
/// axis read seconds from the trigger event.
|
/// axis read seconds from the trigger event.
|
||||||
void set_epoch(f64 epoch) noexcept;
|
void set_epoch(f64 epoch) noexcept;
|
||||||
|
|
||||||
plot::data::RingBuffer<f32>& time() noexcept { return *time_; }
|
/// The shared clock, in **f64**. The values are f32 and the clock is not: a stamp is
|
||||||
const plot::data::RingBuffer<f32>& time() const noexcept { return *time_; }
|
/// `stream_time - epoch` and the epoch is fixed at the first sample ever seen, so the
|
||||||
|
/// magnitude grows without bound over a run's life while the interval that has to be
|
||||||
|
/// resolved stays the sample period. f32 has 24 bits of mantissa, so its ULP at 32 s is
|
||||||
|
/// 3.8 us -- four consecutive samples of a 1 MS/s stream then share one stamp, and at
|
||||||
|
/// 600 s uptime 61 of them do. A value's precision is a property of the signal and 24
|
||||||
|
/// bits is plenty; a *timestamp's* is a property of how long the scope has been running.
|
||||||
|
plot::data::RingBuffer<f64>& time() noexcept { return *time_; }
|
||||||
|
const plot::data::RingBuffer<f64>& time() const noexcept { return *time_; }
|
||||||
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
|
plot::data::RingBuffer<f32>& values(std::size_t channel) { return *values_[channel]; }
|
||||||
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
|
ChannelBuffer& channel(std::size_t channel) { return *channels_[channel]; }
|
||||||
/// Shared handle to a channel view: the caller keeps the storage alive even if
|
/// Shared handle to a channel view: the caller keeps the storage alive even if
|
||||||
@@ -141,7 +146,7 @@ class BufferStore {
|
|||||||
|
|
||||||
private:
|
private:
|
||||||
Partition part_;
|
Partition part_;
|
||||||
std::shared_ptr<plot::data::RingBuffer<f32>> time_;
|
std::shared_ptr<plot::data::RingBuffer<f64>> time_;
|
||||||
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
|
std::vector<std::shared_ptr<plot::data::RingBuffer<f32>>> values_;
|
||||||
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
|
std::vector<std::shared_ptr<ChannelBuffer>> channels_;
|
||||||
};
|
};
|
||||||
@@ -219,6 +224,37 @@ class ScopeModel {
|
|||||||
std::uint64_t math_cost_ns() const noexcept {
|
std::uint64_t math_cost_ns() const noexcept {
|
||||||
return math_cost_ns_.load(std::memory_order_relaxed);
|
return math_cost_ns_.load(std::memory_order_relaxed);
|
||||||
}
|
}
|
||||||
|
/// What freezing a view costs, and *which thread it costs it to*. The two publishes are stalls on
|
||||||
|
/// opposite sides of `mutex()` and so are counted apart: `publish_event()` runs on the **producer**
|
||||||
|
/// with the lock already held, so its cost lands on the UI as a long tick (the documented `tick`
|
||||||
|
/// max); `publish_pause_view()` runs on the **UI** thread and takes the lock, so its cost lands on
|
||||||
|
/// the producer and can only surface as `gaps`. Neither is attributable in a frame mean, which is
|
||||||
|
/// why they are counted rather than inferred. Each counter is written by one thread and read by the
|
||||||
|
/// dump, so a relaxed store of a value already in hand is enough.
|
||||||
|
///
|
||||||
|
/// This is the number to judge a second live ring against: freezing a whole `BufferStore` would
|
||||||
|
/// make both figures zero, since a `ChannelBuffer` maintains its pyramid incrementally and there
|
||||||
|
/// would be nothing to copy -- but it costs a second budget's worth of memory and a second
|
||||||
|
/// per-sample write on the producer, so it is worth paying only if the worst figures here are
|
||||||
|
/// large enough to matter.
|
||||||
|
std::uint64_t publish_event_count() const noexcept {
|
||||||
|
return publish_event_count_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
std::uint64_t publish_event_us_total() const noexcept {
|
||||||
|
return publish_event_us_total_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
std::uint64_t publish_event_us_worst() const noexcept {
|
||||||
|
return publish_event_us_worst_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
std::uint64_t publish_pause_count() const noexcept {
|
||||||
|
return publish_pause_count_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
std::uint64_t publish_pause_us_total() const noexcept {
|
||||||
|
return publish_pause_us_total_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
|
std::uint64_t publish_pause_us_worst() const noexcept {
|
||||||
|
return publish_pause_us_worst_.load(std::memory_order_relaxed);
|
||||||
|
}
|
||||||
const std::vector<SignalInfo>& signals() const noexcept { return signals_; }
|
const std::vector<SignalInfo>& signals() const noexcept { return signals_; }
|
||||||
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
|
const std::vector<ChannelInfo>& channels() const noexcept { return channels_; }
|
||||||
|
|
||||||
@@ -278,23 +314,24 @@ class ScopeModel {
|
|||||||
/// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds,
|
/// The live buffer's absolute index range covering `[lo_s, hi_s]` in stream seconds,
|
||||||
/// clamped to what the time ring and *every* value ring still hold. One range for the
|
/// clamped to what the time ring and *every* value ring still hold. One range for the
|
||||||
/// time ring and all the channels, which is what keeps a cut index-aligned by
|
/// time ring and all the channels, which is what keeps a cut index-aligned by
|
||||||
/// construction; the bounds are compared in the ring's own `f32`, so a bound that
|
/// construction; the bounds are compared in the ring's own type, which is now the
|
||||||
/// lands on a sample includes it whatever the magnitude of the stream time.
|
/// `f64` they arrive in, so a bound that lands on a sample includes it exactly.
|
||||||
///
|
///
|
||||||
/// Caller holds `mutex()` (the acquisition thread already does when it latches).
|
/// Caller holds `mutex()` (the acquisition thread already does when it latches).
|
||||||
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
|
void live_window_locked(double lo_s, double hi_s, u64& first, u64& last);
|
||||||
/// The frozen view taken when the user paused, or null while running. A pause is a
|
/// The frozen view taken when the user paused, or null while running. A pause is a
|
||||||
/// snapshot of the *visible window* in exactly the sense a trigger event is -- an immutable
|
/// snapshot of the *visible window* in exactly the sense a trigger event is -- an immutable
|
||||||
/// series the plot binds to -- and the difference is the anchoring: an event's stamps are
|
/// series the plot binds to -- and both are anchored the same way: the stamps are the
|
||||||
/// edge-relative, a pause's are the stream's own, so the axis does not move when the pause
|
/// coordinates the axis was already reading, with `x_offset` recording which instant that
|
||||||
/// begins and the reader keeps the times they were already reading.
|
/// x = 0 was. So the axis does not move when the pause begins.
|
||||||
std::shared_ptr<const EventSnapshot> pause_view() const noexcept {
|
std::shared_ptr<const EventSnapshot> pause_view() const noexcept {
|
||||||
return pause_view_.load(std::memory_order_acquire);
|
return pause_view_.load(std::memory_order_acquire);
|
||||||
}
|
}
|
||||||
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, the display epoch already
|
/// Snapshots the live ring over `[lo_s, hi_s]` (stream seconds, i.e. the display window put
|
||||||
/// removed) as the pause view. The acquisition thread keeps writing, so this is a copy: what
|
/// back on the stream's clock) as the pause view, rebased onto `x_offset` so its stamps are
|
||||||
/// is frozen is the picture, not the data.
|
/// the x the axis is already showing. The acquisition thread keeps writing, so this is a
|
||||||
void publish_pause_view(double lo_s, double hi_s);
|
/// copy: what is frozen is the picture, not the data.
|
||||||
|
void publish_pause_view(double lo_s, double hi_s, double x_offset);
|
||||||
void clear_pause_view() noexcept { pause_view_.store(nullptr, std::memory_order_release); }
|
void clear_pause_view() noexcept { pause_view_.store(nullptr, std::memory_order_release); }
|
||||||
|
|
||||||
/// Whether the user paused acquisition. Lives here rather than on `Acquisition` because the
|
/// Whether the user paused acquisition. Lives here rather than on `Acquisition` because the
|
||||||
@@ -373,12 +410,30 @@ class ScopeModel {
|
|||||||
return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting;
|
return event() != nullptr ? DisplayMode::Held : DisplayMode::Waiting;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The x origin the plot uses: 0 for "seconds since the stream started", or minus
|
/// The offset the **live** channels report their x with: `display_x = stream_t + epoch`.
|
||||||
/// the trigger instant while in trigger mode, so the axis reads seconds from the
|
/// Minus the newest stored sample's stamp while streaming, so the axis reads
|
||||||
/// event. Applied to the live store's channels.
|
/// `[-window, 0]` and the newest data is at 0; minus the trigger instant while a capture
|
||||||
|
/// is filling, so the axis reads seconds from the event. It is 0 while a *snapshot* is on
|
||||||
|
/// screen (a held event, a paused view), because then the live channels are bound to
|
||||||
|
/// nothing and the snapshot's own `x_offset` is what says which clock x = 0 is --
|
||||||
|
/// `stream_at_x0()` is the accessor that does not have to know which of the two it is.
|
||||||
f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); }
|
f64 display_epoch() const noexcept { return display_epoch_.load(std::memory_order_relaxed); }
|
||||||
void set_display_epoch(f64 e) noexcept;
|
void set_display_epoch(f64 e) noexcept;
|
||||||
|
|
||||||
|
/// The stream time of the display's x = 0, so `stream_t = display_x + stream_at_x0()` and
|
||||||
|
/// `display_x = stream_t - stream_at_x0()` in **every** display mode.
|
||||||
|
///
|
||||||
|
/// One accessor for the two places the offset can live: a snapshot carries its own
|
||||||
|
/// (`EventSnapshot::x_offset`, frozen with the picture), and a live view carries it as
|
||||||
|
/// minus the display epoch. Everything that has to cross between the two clocks goes
|
||||||
|
/// through here -- the export's absolute column, and the pause handler putting a display
|
||||||
|
/// window back on the ring to cut it -- rather than picking the branch itself and getting
|
||||||
|
/// one of the four modes wrong.
|
||||||
|
f64 stream_at_x0() const noexcept {
|
||||||
|
if (std::shared_ptr<const EventSnapshot> snap = display_snapshot()) return snap->x_offset;
|
||||||
|
return -display_epoch();
|
||||||
|
}
|
||||||
|
|
||||||
// --- stream state ---------------------------------------------------------
|
// --- stream state ---------------------------------------------------------
|
||||||
StreamStats& stats() noexcept { return stats_; }
|
StreamStats& stats() noexcept { return stats_; }
|
||||||
const StreamStats& stats() const noexcept { return stats_; }
|
const StreamStats& stats() const noexcept { return stats_; }
|
||||||
@@ -467,6 +522,22 @@ class ScopeModel {
|
|||||||
std::atomic<std::uint64_t> math_slots_{0};
|
std::atomic<std::uint64_t> math_slots_{0};
|
||||||
std::atomic<std::uint64_t> math_timed_{0};
|
std::atomic<std::uint64_t> math_timed_{0};
|
||||||
std::atomic<std::uint64_t> math_cost_ns_{0};
|
std::atomic<std::uint64_t> math_cost_ns_{0};
|
||||||
|
/// See `publish_event_count()`. Single-writer each: the first three are the producer's, the last
|
||||||
|
/// three the UI thread's.
|
||||||
|
std::atomic<std::uint64_t> publish_event_count_{0};
|
||||||
|
std::atomic<std::uint64_t> publish_event_us_total_{0};
|
||||||
|
std::atomic<std::uint64_t> publish_event_us_worst_{0};
|
||||||
|
std::atomic<std::uint64_t> publish_pause_count_{0};
|
||||||
|
std::atomic<std::uint64_t> publish_pause_us_total_{0};
|
||||||
|
std::atomic<std::uint64_t> publish_pause_us_worst_{0};
|
||||||
|
/// Fold one publish's elapsed microseconds into a counter triple. Called by the one thread that
|
||||||
|
/// owns the triple, so the load/store pairs need no read-modify-write.
|
||||||
|
static void note_publish(std::atomic<std::uint64_t>& count, std::atomic<std::uint64_t>& total,
|
||||||
|
std::atomic<std::uint64_t>& worst, std::uint64_t us) noexcept {
|
||||||
|
count.store(count.load(std::memory_order_relaxed) + 1, std::memory_order_relaxed);
|
||||||
|
total.store(total.load(std::memory_order_relaxed) + us, std::memory_order_relaxed);
|
||||||
|
if (us > worst.load(std::memory_order_relaxed)) worst.store(us, std::memory_order_relaxed);
|
||||||
|
}
|
||||||
std::vector<bool> enabled_;
|
std::vector<bool> enabled_;
|
||||||
|
|
||||||
Partition part_;
|
Partition part_;
|
||||||
|
|||||||
+140
-78
@@ -30,10 +30,23 @@ constexpr plot::u32 kRulerBColor = 0x8FA8C8u;
|
|||||||
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
|
/// The measurements' own colour, distinct from both cursor rulers so a readout mark cannot be
|
||||||
/// mistaken for something the user placed.
|
/// mistaken for something the user placed.
|
||||||
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
|
constexpr plot::u32 kMeasureColor = 0x9BE8A0u;
|
||||||
/// The window-start caption's colour: quiet chrome, brighter than a grid line.
|
|
||||||
constexpr plot::u32 kTimeNoteColor = 0xC8C8C8u;
|
|
||||||
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
|
constexpr plot::u32 kTriggerColor = 0xFF5B5Bu;
|
||||||
|
|
||||||
|
/// The smallest difference a `TickFormat::Time` label can *write* at the magnitude `t` -- what
|
||||||
|
/// the last digit of a label at that magnitude is worth. `format_time` writes three significant
|
||||||
|
/// figures below a minute (of the SI-prefixed mantissa under a second, of the seconds above it),
|
||||||
|
/// whole seconds through `m:ss`/`h:mm:ss` up to a day, and three figures of days above. So the
|
||||||
|
/// resolution is a step function of the *magnitude* and not of the span -- which is the whole
|
||||||
|
/// reason a relative axis is ever needed, and the reason a span threshold alone gets it wrong in
|
||||||
|
/// both directions.
|
||||||
|
double time_label_resolution(double t) {
|
||||||
|
t = std::fabs(t);
|
||||||
|
if (!std::isfinite(t) || !(t > 0.0)) return 0.0;
|
||||||
|
if (t >= 86400.0) return 86400.0 * std::pow(10.0, std::floor(std::log10(t / 86400.0)) - 2.0);
|
||||||
|
if (t >= 60.0) return 1.0;
|
||||||
|
return std::pow(10.0, std::floor(std::log10(t)) - 2.0);
|
||||||
|
}
|
||||||
|
|
||||||
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
|
constexpr double kParked = -1.0e12; ///< hides an instrument without removing it
|
||||||
/// The measurement marks' stroke: thinner than the chrome that frames the plot (a trace is 1.5,
|
/// The measurement marks' stroke: thinner than the chrome that frames the plot (a trace is 1.5,
|
||||||
/// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over
|
/// the axis lines and the trigger marker `Style::line_width_px`), and broken, so a readout over
|
||||||
@@ -112,9 +125,9 @@ bool RowSet::remove_row(int at) {
|
|||||||
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
||||||
r.plot = &p;
|
r.plot = &p;
|
||||||
r.axis_x = r.plot->add_x_axis(
|
r.axis_x = r.plot->add_x_axis(
|
||||||
{"time", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
{"", {-1.0, 1.0}, true, plot::AxisPlace::Bottom, false, plot::TickFormat::Time});
|
||||||
r.axis_y = r.plot->add_y_axis(
|
r.axis_y = r.plot->add_y_axis(
|
||||||
{"value", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
{"", {-1.0, 1.0}, true, plot::AxisPlace::Left, false, plot::TickFormat::Si});
|
||||||
for (int s = 0; s < kSlotsPerRow; ++s) {
|
for (int s = 0; s < kSlotsPerRow; ++s) {
|
||||||
const std::size_t i = static_cast<std::size_t>(s);
|
const std::size_t i = static_cast<std::size_t>(s);
|
||||||
r.plot->add_trace(
|
r.plot->add_trace(
|
||||||
@@ -155,11 +168,6 @@ void RowSet::install(Row& r, int /*row_index*/, plot::Plot& p) {
|
|||||||
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
|
r.plot->set_cursor_stroke(r.measure_v[mi], kMeasureStrokeWidth,
|
||||||
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
|
plot::Vec2{kMeasureDashOn, kMeasureDashOff});
|
||||||
}
|
}
|
||||||
// The window-start caption for the relative x labels: an annotation, hidden until the
|
|
||||||
// labels are relative. Inside the axes on purpose -- its text changes with the window, and
|
|
||||||
// an axis title of a changing width would relayout the plot every frame.
|
|
||||||
r.time_origin_note = r.plot->add_annotation(
|
|
||||||
plot::Annotation{0.0, 0.0, "", plot::Color::hex(kTimeNoteColor), r.axis_x, r.axis_y, false});
|
|
||||||
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
|
r.trigger_marker = r.plot->add_cursor(kParked, plot::Color::hex(kTriggerColor));
|
||||||
// A marker, not an instrument: it must sit exactly on the edge's time, so its
|
// A marker, not an instrument: it must sit exactly on the edge's time, so its
|
||||||
// snap is Free and the controller never rounds it onto a sample.
|
// snap is Free and the controller never rounds it onto a sample.
|
||||||
@@ -191,7 +199,7 @@ void RowSet::rebuild_all() {
|
|||||||
}
|
}
|
||||||
figure_->link_x(static_cast<plot::u32>(i), x_group_);
|
figure_->link_x(static_cast<plot::u32>(i), x_group_);
|
||||||
}
|
}
|
||||||
if (y_gutter_px_ > 0.0) apply_y_gutter();
|
apply_gutters();
|
||||||
// A new figure starts with no window at all, so the one the rows were showing has to
|
// A new figure starts with no window at all, so the one the rows were showing has to
|
||||||
// be restated -- as the pin it was, if an event is on screen: assigning a trace or
|
// be restated -- as the pin it was, if an event is on screen: assigning a trace or
|
||||||
// changing the row count while a capture is displayed must not throw the view back
|
// changing the row count while a capture is displayed must not throw the view back
|
||||||
@@ -396,18 +404,30 @@ void RowSet::place_crossings() {
|
|||||||
if (ida >= r.plot->annotation_count() || idb >= r.plot->annotation_count()) continue;
|
if (ida >= r.plot->annotation_count() || idb >= r.plot->annotation_count()) continue;
|
||||||
plot::Annotation a = r.plot->annotation(ida);
|
plot::Annotation a = r.plot->annotation(ida);
|
||||||
plot::Annotation b = r.plot->annotation(idb);
|
plot::Annotation b = r.plot->annotation(idb);
|
||||||
|
double xa = 0.0;
|
||||||
double ya = 0.0;
|
double ya = 0.0;
|
||||||
|
double xb = 0.0;
|
||||||
double yb = 0.0;
|
double yb = 0.0;
|
||||||
const bool has_a = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_a_x_, ya);
|
// `crosshair_point()`, not `crosshair_value()`: the number beside this dot is a *stored
|
||||||
const bool has_b = mix && r.plot->crosshair_value(static_cast<plot::u32>(s), cursor_b_x_, yb);
|
// sample's*, so the dot belongs on that sample and not on the marker's own column. The
|
||||||
|
// two differ by up to half a sample period, which is nothing at a whole-window view and
|
||||||
|
// the whole width of the panel once the view is zoomed to a handful of samples -- the
|
||||||
|
// state in which a reader is asking "which point is that?" in the first place. It leaves
|
||||||
|
// the dot a little off its vertical line by design: the line says *where the marker is*
|
||||||
|
// and the dot says *which datum is being quoted*, and when the marker is between two
|
||||||
|
// samples those are not the same place.
|
||||||
|
const bool has_a =
|
||||||
|
mix && r.plot->crosshair_point(static_cast<plot::u32>(s), cursor_a_x_, xa, ya);
|
||||||
|
const bool has_b =
|
||||||
|
mix && r.plot->crosshair_point(static_cast<plot::u32>(s), cursor_b_x_, xb, yb);
|
||||||
a.visible = has_a;
|
a.visible = has_a;
|
||||||
b.visible = has_b;
|
b.visible = has_b;
|
||||||
if (has_a) {
|
if (has_a) {
|
||||||
a.x = cursor_a_x_;
|
a.x = xa;
|
||||||
a.y = ya;
|
a.y = ya;
|
||||||
}
|
}
|
||||||
if (has_b) {
|
if (has_b) {
|
||||||
b.x = cursor_b_x_;
|
b.x = xb;
|
||||||
b.y = yb;
|
b.y = yb;
|
||||||
}
|
}
|
||||||
r.plot->set_annotation(ida, a);
|
r.plot->set_annotation(ida, a);
|
||||||
@@ -497,12 +517,48 @@ bool RowSet::update_time_labels() {
|
|||||||
const double span = view.span();
|
const double span = view.span();
|
||||||
if (!(span > 0.0) || !std::isfinite(span)) return false;
|
if (!(span > 0.0) || !std::isfinite(span)) return false;
|
||||||
|
|
||||||
// Sub-second windows read relative: `TickFormat::Time` writes three significant digits, so
|
// A relative axis is the answer to one problem only: a tick step the label form cannot write.
|
||||||
// `12.345671s` and `12.345672s` are the same string and a 1 ms step around a multi-second
|
// `TickFormat::Time` carries three significant digits, so a 1 ms step around a 12 s stamp
|
||||||
// time cannot be labelled at all. The band is the switch's hysteresis -- entering below a
|
// writes `12.3s` on every tick -- there the ticks have to be re-anchored and the anchor named.
|
||||||
// second, leaving only above 1.05 -- so a span sitting on the boundary cannot flicker
|
// The test is therefore the *step against the resolution at the axis' own magnitude*, never the
|
||||||
// between two forms while a zoom is nudged.
|
// span: the two part company in both directions, and both cases are real here. A 2 s window at
|
||||||
const bool want_relative = relative_x_ ? span < 1.05 : span < 1.0;
|
// twenty minutes of uptime is labelled `20:00` throughout and needs the anchor although its
|
||||||
|
// span is wide; a 50 ms trigger window spanning [-25ms, +25ms] is labelled `-20ms .. +20ms`
|
||||||
|
// perfectly well and needs nothing, although its span is narrow. That second case is what made
|
||||||
|
// a triggered sub-second view carry a `t0` caption *and* re-anchor its ticks away from the
|
||||||
|
// edge, which is the one position a triggered axis exists to show.
|
||||||
|
// The 1.05 band is the switch's hysteresis, so a step sitting on the boundary cannot flicker
|
||||||
|
// between two forms while a zoom is nudged, and the `1 - 1e-9` is a slack the *entry* needs for
|
||||||
|
// its own reason: a step read back from two tick values and a resolution from `pow(10, n)`
|
||||||
|
// differ in their last bits even when they are the same number, and `step == res` is a readable
|
||||||
|
// axis (`1.73s, 1.74s, 1.75s` -- one in the last digit is exactly enough), so a decision taken
|
||||||
|
// on those bits picked a different form from one frame to the next.
|
||||||
|
const double mag = std::max(std::fabs(view.min), std::fabs(view.max));
|
||||||
|
const double res = time_label_resolution(mag);
|
||||||
|
// The step the labels are actually written at: the majors the *last* layout produced. They are
|
||||||
|
// a function of the range and the axis' pixel width, not of the label form, so reading them
|
||||||
|
// back here is no feedback loop.
|
||||||
|
double step = 0.0;
|
||||||
|
{
|
||||||
|
const plot::Axis& ax = first.plot->x_axis(first.axis_x);
|
||||||
|
double prev = 0.0;
|
||||||
|
bool have = false;
|
||||||
|
for (const plot::Tick& t : ax.ticks) {
|
||||||
|
if (!t.major) continue;
|
||||||
|
if (have) {
|
||||||
|
step = t.value - prev;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
prev = t.value;
|
||||||
|
have = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No ticks yet -- the frames before the first layout. The form is a property of the labels, so
|
||||||
|
// with no labels there is nothing to decide, and keeping the current one is right where
|
||||||
|
// guessing a step from the span is not: a guess that entered the relative form would be
|
||||||
|
// *latched* there by the hysteresis above.
|
||||||
|
const bool want_relative =
|
||||||
|
step > 0.0 ? step < res * (relative_x_ ? 1.05 : 1.0 - 1.0e-9) : relative_x_;
|
||||||
const bool changed = want_relative != relative_x_;
|
const bool changed = want_relative != relative_x_;
|
||||||
relative_x_ = want_relative;
|
relative_x_ = want_relative;
|
||||||
|
|
||||||
@@ -518,27 +574,25 @@ bool RowSet::update_time_labels() {
|
|||||||
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
|
r.plot->set_x_axis_origin_from_range(r.axis_x, relative_x_);
|
||||||
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
|
if (!relative_x_) r.plot->set_x_axis_origin(r.axis_x, 0.0);
|
||||||
// The caption belongs with the labels, which a figure draws once, on the bottom row (the
|
// The caption belongs with the labels, which a figure draws once, on the bottom row (the
|
||||||
// one whose x axis is the figure's). Every row gets it parked.
|
// one whose x axis is the figure's). Every other row gets an empty one, which draws nothing.
|
||||||
const bool bottom = i == rows_ - 1;
|
const bool bottom = i == rows_ - 1;
|
||||||
if (r.time_origin_note >= r.plot->annotation_count()) continue;
|
if (!relative_x_ || !bottom) {
|
||||||
plot::Annotation note = r.plot->annotation(r.time_origin_note);
|
r.plot->set_x_note("");
|
||||||
note.visible = relative_x_ && bottom;
|
continue;
|
||||||
if (note.visible) {
|
|
||||||
// Inside the axes, just above the axis line, at their left edge: the reader adds this
|
|
||||||
// to a tick, so it sits beside the ticks.
|
|
||||||
const plot::Range yr = r.plot->y_range(r.axis_y);
|
|
||||||
note.x = view.min;
|
|
||||||
note.y = yr.min + 0.04 * (yr.span() > 0.0 ? yr.span() : 1.0);
|
|
||||||
char text[64];
|
|
||||||
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
|
|
||||||
// layout set from its range. Reading it back (rather than keeping our own copy) is what
|
|
||||||
// makes the reader's addition exact -- label plus caption is that tick's absolute time.
|
|
||||||
const double shown = r.plot->x_axis_origin(r.axis_x);
|
|
||||||
x_origin_ = shown;
|
|
||||||
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
|
|
||||||
note.text = text;
|
|
||||||
}
|
}
|
||||||
r.plot->set_annotation(r.time_origin_note, note);
|
char text[64];
|
||||||
|
// The anchor the *drawn* labels were built with: the axis' own origin, which the last
|
||||||
|
// layout set from its range. Reading it back (rather than keeping our own copy) is what
|
||||||
|
// makes the reader's addition exact -- label plus caption is that tick's absolute time.
|
||||||
|
const double shown = r.plot->x_axis_origin(r.axis_x);
|
||||||
|
x_origin_ = shown;
|
||||||
|
std::snprintf(text, sizeof(text), "t0 = %.6g s", shown);
|
||||||
|
// Chrome, not an annotation: an annotation is anchored to a *data* coordinate, so the caption
|
||||||
|
// crawled across the plot as the view panned and drifted as it zoomed -- and it came with the
|
||||||
|
// annotation's own pin cross, which pointed at nothing. `set_x_note()` places it in the axes'
|
||||||
|
// bottom-left corner in pixels, on the legend's backplate, which is where the labels it
|
||||||
|
// qualifies are.
|
||||||
|
r.plot->set_x_note(text);
|
||||||
}
|
}
|
||||||
return changed;
|
return changed;
|
||||||
}
|
}
|
||||||
@@ -646,20 +700,26 @@ void RowSet::unpin_pause_window() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void RowSet::fit_x() {
|
void RowSet::fit_x() {
|
||||||
Row& first = row(0);
|
if (!figure_) return;
|
||||||
if (!first.plot || !figure_) return;
|
if (!pinned_) {
|
||||||
first.plot->auto_fit_x();
|
// Live: the same path the window control and a resume take, with the window it already has.
|
||||||
const plot::Range fitted = first.plot->x_range(first.axis_x);
|
// That re-arms the follow and parks the newest `window_s_` at the live edge, which is the
|
||||||
const double span = fitted.span();
|
// whole of what a live view may show.
|
||||||
if (!std::isfinite(span) || !(span > 0.0)) return;
|
set_time_window(window_s_);
|
||||||
// The same two paths `fit_all()` takes: a held event owns its window and states it
|
return;
|
||||||
// through the link group, a live view re-arms the follow and frames the newest span.
|
|
||||||
if (pinned_) {
|
|
||||||
window_s_ = span;
|
|
||||||
figure_->set_x_link_range(x_group_, fitted);
|
|
||||||
} else {
|
|
||||||
set_time_window(span);
|
|
||||||
}
|
}
|
||||||
|
// Pinned (a held event or a paused view): the pin's full range, and the pin stays. The
|
||||||
|
// *members* have to be written as well as the group, because `hold_pinned_window()` prefers a
|
||||||
|
// member that has moved since our own last statement -- that is how it tells a gesture from its
|
||||||
|
// own echo -- so writing the group alone would be reverted to the zoomed-in member next frame.
|
||||||
|
const plot::Range pin{-pinned_pre_, pinned_post_};
|
||||||
|
if (!(pin.span() > 0.0) || !std::isfinite(pin.min) || !std::isfinite(pin.max)) return;
|
||||||
|
for (int i = 0; i < rows_; ++i) {
|
||||||
|
Row& r = row(i);
|
||||||
|
if (r.plot) r.plot->set_x_range(r.axis_x, pin);
|
||||||
|
}
|
||||||
|
pinned_stated_ = pin;
|
||||||
|
figure_->set_x_link_range(x_group_, pin);
|
||||||
}
|
}
|
||||||
|
|
||||||
void RowSet::sync_cursors(int drag_row) {
|
void RowSet::sync_cursors(int drag_row) {
|
||||||
@@ -778,30 +838,22 @@ void RowSet::fit_all() {
|
|||||||
Row& r = row(i);
|
Row& r = row(i);
|
||||||
if (r.plot) r.plot->auto_fit_y(r.axis_y);
|
if (r.plot) r.plot->auto_fit_y(r.axis_y);
|
||||||
}
|
}
|
||||||
Row& first = row(0);
|
// The x half is `fit_x()`'s exactly -- the zoom undone, the time window untouched -- and
|
||||||
if (!first.plot || !figure_) return;
|
// sharing it is what keeps the two fits from drifting apart.
|
||||||
// Row 0 fits its own x to the data it holds; that span becomes the shared window, and
|
fit_x();
|
||||||
// stating it through the group is what puts every row on it.
|
|
||||||
first.plot->auto_fit_x();
|
|
||||||
const plot::Range fitted = first.plot->x_range(first.axis_x);
|
|
||||||
const double span = fitted.span();
|
|
||||||
if (!std::isfinite(span) || !(span > 0.0)) return;
|
|
||||||
if (pinned_) {
|
|
||||||
// Fitting a captured event: the window is the snapshot's own `[-pre, +post]` span,
|
|
||||||
// and `set_time_window()` would re-arm the follow and re-frame on a live edge that
|
|
||||||
// trigger mode has nothing bound to.
|
|
||||||
window_s_ = span;
|
|
||||||
figure_->set_x_link_range(x_group_, fitted);
|
|
||||||
} else {
|
|
||||||
set_time_window(span);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void RowSet::note_y_gutter(double need_px) {
|
void RowSet::note_y_gutter(double need_px) {
|
||||||
double want = std::min(need_px + 6.0, 200.0);
|
// No slack added here, and that is load-bearing: `need_px` is the gutter the row was
|
||||||
|
// *laid out with*, which is already the maximum of its labels and the floor imposed last
|
||||||
|
// frame -- so a few pixels added to it are added again next frame. The `+ 6.0` this used
|
||||||
|
// to carry made the gutter a ratchet that climbed 6 px a frame until it hit the cap
|
||||||
|
// below, which is the whole reason the left margin was `200` of an 870 px viewport (and
|
||||||
|
// the `axes.w changes: 17` beside it). The label padding the reservation below adds is
|
||||||
|
// the slack, once.
|
||||||
|
double want = std::min(need_px, 200.0);
|
||||||
// Reserve room for the widest label the formatter can produce, once, with the
|
// Reserve room for the widest label the formatter can produce, once, with the
|
||||||
// font the labels are actually drawn in (the embedded font is monospaced, so a
|
// font the labels are actually drawn in.
|
||||||
// character count is an exact width).
|
|
||||||
if (!y_gutter_reserved_) {
|
if (!y_gutter_reserved_) {
|
||||||
for (int i = 0; i < rows_; ++i) {
|
for (int i = 0; i < rows_; ++i) {
|
||||||
Row& r = row(i);
|
Row& r = row(i);
|
||||||
@@ -809,10 +861,19 @@ void RowSet::note_y_gutter(double need_px) {
|
|||||||
const plot::render::TextBackend* text = r.plot->text_backend();
|
const plot::render::TextBackend* text = r.plot->text_backend();
|
||||||
if (text == nullptr) continue;
|
if (text == nullptr) continue;
|
||||||
const plot::render::Style& style = r.plot->style();
|
const plot::render::Style& style = r.plot->style();
|
||||||
const double advance = double(text->measure("0123456789")) / 10.0;
|
|
||||||
const double pad = double(style.device(style.grid_label_pad));
|
const double pad = double(style.device(style.grid_label_pad));
|
||||||
// "-1.23e-45" is the longest label `format_si()`'s fallback can print.
|
// A sign, three significant figures and a one-glyph SI prefix: the widest label
|
||||||
want = std::max(want, 9.0 * advance + 2.0 * pad + 2.0);
|
// `format_si()` writes for an axis with no unit, measured rather than counted in
|
||||||
|
// characters. Its `%.3g` fallback is wider (`-1.23e-45`) and was what this reserved,
|
||||||
|
// which cost a *third* of the gutter for a label no signal on a `Si` axis produces --
|
||||||
|
// it needs a value past 1e24, and `note_y_gutter()` is a monotone maximum over the
|
||||||
|
// measured need, so one that did appear widens the gutter for good anyway.
|
||||||
|
want = std::max(want, double(text->measure("-1.23m")) + 2.0 * pad + 2.0);
|
||||||
|
// The other side, from the same metrics. An x tick label is *centred* on its tick and
|
||||||
|
// the newest tick sits on the axes' right edge (a live axis is [-window, 0]), so half
|
||||||
|
// the widest time label is what the rightmost one needs to be drawn at all -- and that
|
||||||
|
// is also the band which keeps the newest sample's ink off the frame.
|
||||||
|
right_gutter_px_ = 0.5 * double(text->measure("-1.23ms")) + pad;
|
||||||
y_gutter_reserved_ = true;
|
y_gutter_reserved_ = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -820,9 +881,10 @@ void RowSet::note_y_gutter(double need_px) {
|
|||||||
if (want > y_gutter_px_) y_gutter_px_ = want;
|
if (want > y_gutter_px_) y_gutter_px_ = want;
|
||||||
}
|
}
|
||||||
|
|
||||||
void RowSet::apply_y_gutter() {
|
void RowSet::apply_gutters() {
|
||||||
if (!figure_ || !(y_gutter_px_ > 0.0)) return;
|
if (!figure_) return;
|
||||||
figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
|
if (y_gutter_px_ > 0.0) figure_->set_min_y_gutter(static_cast<f32>(y_gutter_px_));
|
||||||
|
figure_->set_right_gutter(static_cast<f32>(right_gutter_px_));
|
||||||
}
|
}
|
||||||
|
|
||||||
void RowSet::set_trigger_marker(double x, bool visible) {
|
void RowSet::set_trigger_marker(double x, bool visible) {
|
||||||
|
|||||||
+51
-24
@@ -106,11 +106,6 @@ class RowSet {
|
|||||||
/// it keeps its position whatever the cursor toggle says.
|
/// it keeps its position whatever the cursor toggle says.
|
||||||
plot::u32 trigger_marker = 0;
|
plot::u32 trigger_marker = 0;
|
||||||
|
|
||||||
/// The caption naming the window's start while the x labels are relative: an annotation
|
|
||||||
/// inside the axes, so its text can change every frame without moving the layout (an axis
|
|
||||||
/// *title* would, and it would be a different width each time).
|
|
||||||
plot::u32 time_origin_note = 0;
|
|
||||||
|
|
||||||
/// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
|
/// The measurements' readout marks: a pool created with the plot (a `Plot` cannot add or
|
||||||
/// drop a cursor after the fact) and parked unless a measurement is using the slot.
|
/// drop a cursor after the fact) and parked unless a measurement is using the slot.
|
||||||
std::array<plot::u32, kMeasureMarks> measure_h{};
|
std::array<plot::u32, kMeasureMarks> measure_h{};
|
||||||
@@ -183,6 +178,14 @@ class RowSet {
|
|||||||
void set_time_window(double seconds);
|
void set_time_window(double seconds);
|
||||||
double time_window() const noexcept { return window_s_; }
|
double time_window() const noexcept { return window_s_; }
|
||||||
|
|
||||||
|
/// The newest x any row's live traces hold, which is what the link group follows -- and, since
|
||||||
|
/// it is reported in the display epoch currently in force, also what the frame tick subtracts
|
||||||
|
/// from that epoch to park the newest sample at x = 0. It must be read **after** `bind()` and
|
||||||
|
/// `acquire()`: it asks the bound series, so before them it answers for the previous frame's
|
||||||
|
/// window, and a frame's production at 1 MS/s over a 50 ms view is a third of the screen.
|
||||||
|
/// False when no row holds a streaming trace.
|
||||||
|
bool live_edge(double& newest) const;
|
||||||
|
|
||||||
void set_y_auto(int row, bool on);
|
void set_y_auto(int row, bool on);
|
||||||
void set_y_range(int row, double lo, double hi);
|
void set_y_range(int row, double lo, double hi);
|
||||||
|
|
||||||
@@ -196,7 +199,13 @@ class RowSet {
|
|||||||
void place_crossings();
|
void place_crossings();
|
||||||
CursorMode cursor_mode() const noexcept { return cursor_mode_; }
|
CursorMode cursor_mode() const noexcept { return cursor_mode_; }
|
||||||
|
|
||||||
/// The two vertical cursors' stream times; shared by every row.
|
/// The two vertical cursors' positions, in the **display** x the axis reads -- not stream
|
||||||
|
/// seconds, which they coincided with only while `display_epoch()` was always 0. A live view
|
||||||
|
/// is newest-relative now, so a cursor left at -1.5 s keeps meaning "1.5 s ago" and the data
|
||||||
|
/// flows under it, which is what a bench scope's roll mode does and is the behaviour that was
|
||||||
|
/// asked for. They are read back out of the plots and written to them unchanged, so there is
|
||||||
|
/// nothing to convert; anything that needs the stream's own clock goes through
|
||||||
|
/// `ScopeModel::stream_at_x0()`. Shared by every row.
|
||||||
double cursor_a_x() const noexcept { return cursor_a_x_; }
|
double cursor_a_x() const noexcept { return cursor_a_x_; }
|
||||||
double cursor_b_x() const noexcept { return cursor_b_x_; }
|
double cursor_b_x() const noexcept { return cursor_b_x_; }
|
||||||
/// One row's ruler values, or 0 for a row that does not exist.
|
/// One row's ruler values, or 0 for a row that does not exist.
|
||||||
@@ -298,11 +307,25 @@ class RowSet {
|
|||||||
void set_data_pin(double lo, double hi);
|
void set_data_pin(double lo, double hi);
|
||||||
void unpin_pause_window();
|
void unpin_pause_window();
|
||||||
|
|
||||||
/// Frames the x window on the data row 0 holds, leaving every y axis alone. The x
|
/// Undoes an x zoom: puts the view back on the whole time window, leaving every y axis alone.
|
||||||
/// counterpart of `fit_all()`, for the one thing the y axes cannot say: how wide the
|
/// The x counterpart of `fit_all()`.
|
||||||
/// window should be.
|
///
|
||||||
|
/// **It does not change the time window** -- `window_s_` is read, never written. A fit is a
|
||||||
|
/// plot interaction, and the window is also the trigger's event span, the buffer partition and
|
||||||
|
/// the control's readout, so a fit that *framed the data* instead would redefine all of that
|
||||||
|
/// from a gesture (and re-plan the rings, dropping the history). What a user asks for here is
|
||||||
|
/// the zoom undone: a 2 s window zoomed into reads 2 s again.
|
||||||
|
///
|
||||||
|
/// Which 2 s depends on the state, and both cases restore the *whole* of what the view is
|
||||||
|
/// allowed to show: live, the newest window at the live edge; pinned (a held event or a paused
|
||||||
|
/// view), the pin's full range, with the pin kept -- `set_time_window()` would clear it and
|
||||||
|
/// re-frame on a live edge that a frozen view has nothing bound to.
|
||||||
void fit_x();
|
void fit_x();
|
||||||
|
|
||||||
|
/// Whether a window is pinned: a held event, a filling capture or a paused view. The condition
|
||||||
|
/// `fit_x()` branches on, which is why the fit probe reports it.
|
||||||
|
bool window_pinned() const noexcept { return pinned_; }
|
||||||
|
|
||||||
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
|
/// Adopts the instruments a gesture is moving, on `drag_row` (the row whose controller holds
|
||||||
/// a cursor), and nothing otherwise.
|
/// a cursor), and nothing otherwise.
|
||||||
///
|
///
|
||||||
@@ -336,10 +359,9 @@ class RowSet {
|
|||||||
/// 0, so nothing outside the pre+post window is visible.
|
/// 0, so nothing outside the pre+post window is visible.
|
||||||
void pin_event_window(double pre_s, double post_s);
|
void pin_event_window(double pre_s, double post_s);
|
||||||
|
|
||||||
/// Fits every row's y axis to the data it holds, and re-frames the shared x window
|
/// Fits every row's y axis to the data it holds, and puts x back on the whole time window --
|
||||||
/// on what row 0 retains (the same thing the plot's own `f` key does). The window
|
/// `fit_x()`'s x half exactly, so the two cannot drift apart. Like it, **this does not change
|
||||||
/// the toolbar reports becomes the fitted span, so the dropdown can say what is
|
/// the time window**: the y axes are fitted to their data, x is merely un-zoomed.
|
||||||
/// actually on screen.
|
|
||||||
void fit_all();
|
void fit_all();
|
||||||
|
|
||||||
/// Reports the left gutter the rows currently need, and reserves at least the width
|
/// Reports the left gutter the rows currently need, and reserves at least the width
|
||||||
@@ -351,18 +373,22 @@ class RowSet {
|
|||||||
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
|
/// left and right by a few pixels every frame. Since `set_min_y_gutter()` is a
|
||||||
/// floor and the library still takes the larger of the two, a floor smaller than
|
/// floor and the library still takes the larger of the two, a floor smaller than
|
||||||
/// the labels cannot stop that; the reservation therefore covers the widest label
|
/// the labels cannot stop that; the reservation therefore covers the widest label
|
||||||
/// `format_si()` can produce (three significant digits, a sign and a prefix, or the
|
/// `format_si()` writes for an axis with no unit -- a sign, three significant figures
|
||||||
/// `%.3g` fallback) plus the label padding, measured once with the plot's own font.
|
/// and a one-glyph prefix -- plus the label padding, measured once with the plot's own
|
||||||
/// Both reservations only ever grow, so the gutter is constant in practice.
|
/// font. Both reservations only ever grow, so the gutter is constant in practice.
|
||||||
|
///
|
||||||
|
/// The same metrics fix the **right** gutter, which is a want and not a measurement:
|
||||||
|
/// see `apply_gutters()`.
|
||||||
void note_y_gutter(double need_px);
|
void note_y_gutter(double need_px);
|
||||||
|
|
||||||
/// Applies the stable gutter as the figure's floor. Call once per frame, before the
|
/// Applies the stable left gutter as the figure's floor, and the right gutter beside
|
||||||
/// frame is drawn.
|
/// it. Call once per frame, before the frame is drawn.
|
||||||
///
|
///
|
||||||
/// It has to be the *figure's* floor and not each row's: `Figure::layout()` clears
|
/// Both have to be the *figure's* and not each row's: `Figure::layout()` clears every
|
||||||
/// every row's `set_min_y_gutter()` to measure what its own labels need, so a floor
|
/// row's `set_min_y_gutter()` to measure what its own labels need, so a floor written
|
||||||
/// written onto the rows survives exactly until the next layout.
|
/// onto the rows survives exactly until the next layout, and the rows have to end on
|
||||||
void apply_y_gutter();
|
/// one column as well as start on one.
|
||||||
|
void apply_gutters();
|
||||||
|
|
||||||
/// The palette entry `index`, wrapped. Keyed by the *signal*, so that the colour a
|
/// The palette entry `index`, wrapped. Keyed by the *signal*, so that the colour a
|
||||||
/// trace is drawn in is the colour the signal list's chip shows, on every row the
|
/// trace is drawn in is the colour the signal list's chip shows, on every row the
|
||||||
@@ -391,8 +417,6 @@ class RowSet {
|
|||||||
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot
|
/// at the end of the gesture (`Controller::end_drag()`), and a value read out of the plot
|
||||||
/// mid-drag has already been through that act.
|
/// mid-drag has already been through that act.
|
||||||
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped);
|
void place_cursors(Row& r, plot::CursorPlacement placement = plot::CursorPlacement::Snapped);
|
||||||
/// The newest x any row's live traces hold, which is what the group follows.
|
|
||||||
bool live_edge(double& newest) const;
|
|
||||||
|
|
||||||
std::unique_ptr<plot::Figure> figure_;
|
std::unique_ptr<plot::Figure> figure_;
|
||||||
/// The one x link group every visible row belongs to. Groups are numbered from 1.
|
/// The one x link group every visible row belongs to. Groups are numbered from 1.
|
||||||
@@ -411,6 +435,9 @@ class RowSet {
|
|||||||
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back.
|
/// gesture to be honoured; one that matches it is just the clamp's own answer arriving back.
|
||||||
plot::Range pinned_stated_{0.0, 0.0};
|
plot::Range pinned_stated_{0.0, 0.0};
|
||||||
double y_gutter_px_ = 0.0;
|
double y_gutter_px_ = 0.0;
|
||||||
|
/// The empty band kept to the right of the data, device pixels. Nothing is drawn in it,
|
||||||
|
/// so it is reserved once from the font's metrics and never measured or grown.
|
||||||
|
double right_gutter_px_ = 0.0;
|
||||||
bool y_gutter_reserved_ = false;
|
bool y_gutter_reserved_ = false;
|
||||||
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
|
std::array<Row, static_cast<std::size_t>(kMaxRows)> storage_{};
|
||||||
CursorMode cursor_mode_ = CursorMode::Vertical;
|
CursorMode cursor_mode_ = CursorMode::Vertical;
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
#include "shortcuts.hpp"
|
||||||
|
|
||||||
|
#include <gdk/gdk.h>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// Bare letters are toggles, Ctrl is for files and windows. Nothing here shadows a key the
|
||||||
|
/// plot's own controller handles ('+', '-', 'f', the arrows, Escape).
|
||||||
|
constexpr Accel kAccels[] = {
|
||||||
|
{GDK_KEY_space, 0, AppAction::PauseToggle, "space", "Pause or resume the display",
|
||||||
|
"Acquisition"},
|
||||||
|
{GDK_KEY_a, 0, AppAction::TriggerArm, "a", "Arm or disarm the trigger", "Acquisition"},
|
||||||
|
{GDK_KEY_t, 0, AppAction::TriggerPanel, "t", "Show or hide the trigger panel",
|
||||||
|
"Acquisition"},
|
||||||
|
{GDK_KEY_c, 0, AppAction::CursorsPane, "c", "Show or hide the cursors pane",
|
||||||
|
"Panes and editors"},
|
||||||
|
{GDK_KEY_m, 0, AppAction::MeasuresPane, "m", "Show or hide the measurements pane",
|
||||||
|
"Panes and editors"},
|
||||||
|
{GDK_KEY_g, 0, AppAction::MathPane, "g", "Show or hide the math pane",
|
||||||
|
"Panes and editors"},
|
||||||
|
{GDK_KEY_l, 0, AppAction::LayoutEditor, "l", "Show or hide the layout editor",
|
||||||
|
"Panes and editors"},
|
||||||
|
{GDK_KEY_s, 0, AppAction::SidePanel, "s", "Show or hide the side panel",
|
||||||
|
"Panes and editors"},
|
||||||
|
{GDK_KEY_e, GDK_CONTROL_MASK, AppAction::Export, "<Control>e",
|
||||||
|
"Export the visible range", "Files and windows"},
|
||||||
|
{GDK_KEY_F1, 0, AppAction::Help, "F1", "Open the help window", "Files and windows"},
|
||||||
|
{GDK_KEY_slash, GDK_CONTROL_MASK, AppAction::Shortcuts, "<Control>slash",
|
||||||
|
"Open this shortcut overview", "Files and windows"},
|
||||||
|
{GDK_KEY_q, GDK_CONTROL_MASK, AppAction::Quit, "<Control>q", "Quit udpscope",
|
||||||
|
"Files and windows"},
|
||||||
|
};
|
||||||
|
constexpr int kAccelCount = static_cast<int>(sizeof(kAccels) / sizeof(kAccels[0]));
|
||||||
|
|
||||||
|
/// The plot's own interactions, documentation only. The keyboard's modifiers are not the
|
||||||
|
/// wheel's: '+' is shift+'=' on common layouts, so Shift could not mean anything there.
|
||||||
|
///
|
||||||
|
/// A space-separated accelerator is the two-key alternative form the shortcut label draws
|
||||||
|
/// as `+ / -`. The mouse rows carry no accelerator at all: neither GTK's accelerator
|
||||||
|
/// syntax nor libadwaita's knows a wheel or a drag, and asking either to parse "Wheel"
|
||||||
|
/// is a parse warning and an empty cell. Those rows are prose in the subtitle instead.
|
||||||
|
constexpr Gesture kGestures[] = {
|
||||||
|
{"plus minus", "", "Zoom in or out, both axes", "Plot keyboard"},
|
||||||
|
{"<Control>plus <Control>minus", "", "Zoom the time axis only", "Plot keyboard"},
|
||||||
|
{"<Alt>plus <Alt>minus", "", "Zoom the value axis only", "Plot keyboard"},
|
||||||
|
{"Left Right", "", "Pan along time", "Plot keyboard"},
|
||||||
|
{"Up Down", "", "Pan along values", "Plot keyboard"},
|
||||||
|
{"f", "", "Fit the view to the data", "Plot keyboard"},
|
||||||
|
{"Escape", "", "Cancel the gesture in progress", "Plot keyboard"},
|
||||||
|
{"", "Wheel", "Zoom about the pointer, both axes", "Plot mouse"},
|
||||||
|
{"", "Shift and wheel", "Zoom the time axis only", "Plot mouse"},
|
||||||
|
{"", "Ctrl and wheel", "Zoom the value axis only", "Plot mouse"},
|
||||||
|
{"", "Drag", "Pan", "Plot mouse"},
|
||||||
|
{"", "Ctrl and drag", "Box zoom on time", "Plot mouse"},
|
||||||
|
{"", "Alt and drag", "Box zoom on values", "Plot mouse"},
|
||||||
|
};
|
||||||
|
constexpr int kGestureCount = static_cast<int>(sizeof(kGestures) / sizeof(kGestures[0]));
|
||||||
|
|
||||||
|
/// The only modifiers a table row may require. Lock, Num-Lock and the pointer buttons all
|
||||||
|
/// arrive in the same mask and must be masked out or no row ever matches.
|
||||||
|
constexpr unsigned kModMask = GDK_CONTROL_MASK | GDK_SHIFT_MASK | GDK_ALT_MASK;
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
const Accel* app_accels(int* count) {
|
||||||
|
if (count != nullptr) *count = kAccelCount;
|
||||||
|
return kAccels;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Gesture* plot_gestures(int* count) {
|
||||||
|
if (count != nullptr) *count = kGestureCount;
|
||||||
|
return kGestures;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Accel* find_accel(unsigned keyval, unsigned mods) {
|
||||||
|
const unsigned wanted = mods & kModMask;
|
||||||
|
for (const Accel& accel : kAccels) {
|
||||||
|
if (accel.keyval == keyval && accel.mods == wanted) return &accel;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// Everything a key can ask the application to do. The plot's own actions (zoom, pan, fit)
|
||||||
|
/// are not here: they belong to the plotter's input controller, which handles its own keys.
|
||||||
|
enum class AppAction {
|
||||||
|
PauseToggle,
|
||||||
|
TriggerArm,
|
||||||
|
TriggerPanel,
|
||||||
|
CursorsPane,
|
||||||
|
MeasuresPane,
|
||||||
|
MathPane,
|
||||||
|
LayoutEditor,
|
||||||
|
SidePanel,
|
||||||
|
Export,
|
||||||
|
Help,
|
||||||
|
Shortcuts,
|
||||||
|
Quit,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One application accelerator: what to press, what it does, and where the overview lists it.
|
||||||
|
struct Accel {
|
||||||
|
unsigned keyval; ///< GDK keyval
|
||||||
|
unsigned mods; ///< GdkModifierType mask, 0 for a bare key
|
||||||
|
AppAction action;
|
||||||
|
const char* accel; ///< GTK accelerator syntax, as `gtk_accelerator_parse()` reads it
|
||||||
|
const char* description;
|
||||||
|
const char* section;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// One documented plot interaction. These have no AppAction: the plotter's own controller
|
||||||
|
/// implements them, and this table exists so the overview can list them.
|
||||||
|
struct Gesture {
|
||||||
|
const char* accel; ///< GTK accelerator syntax, or "" for a pointer gesture
|
||||||
|
const char* subtitle; ///< prose for what no accelerator can express ("Wheel"), else ""
|
||||||
|
const char* description;
|
||||||
|
const char* section;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The application accelerator table. Writes the row count to `count`.
|
||||||
|
const Accel* app_accels(int* count);
|
||||||
|
|
||||||
|
/// The plot keyboard and mouse table. Writes the row count to `count`.
|
||||||
|
const Gesture* plot_gestures(int* count);
|
||||||
|
|
||||||
|
/// The accelerator for `keyval` with `mods`, or nullptr. `mods` is masked to
|
||||||
|
/// Ctrl/Shift/Alt before comparing, because Lock, Num-Lock and the pointer buttons
|
||||||
|
/// arrive in the same mask and would otherwise never match a table row.
|
||||||
|
const Accel* find_accel(unsigned keyval, unsigned mods);
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
#include "layout_store.hpp"
|
#include "layout_store.hpp"
|
||||||
|
|
||||||
|
#include <adwaita.h>
|
||||||
#include <gtk/gtk.h>
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
@@ -351,7 +352,10 @@ bool choose_source(Config& cfg) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
gtk_init();
|
// `adw_init()` rather than `gtk_init()`: it does everything the latter does and also
|
||||||
|
// installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog`
|
||||||
|
// is drawn with. Both are idempotent, so `run_ui()` calling it again costs nothing.
|
||||||
|
adw_init();
|
||||||
|
|
||||||
Chooser c;
|
Chooser c;
|
||||||
c.cfg = &cfg;
|
c.cfg = &cfg;
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
#include "status_text.hpp"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// `12 s`, `3m 07s`, `2h 14m` -- one unit coarser as soon as the finer one would need
|
||||||
|
/// three digits, so the field never grows past six characters and the status line's shape
|
||||||
|
/// does not depend on how long the wait has been.
|
||||||
|
std::string format_elapsed(double seconds) {
|
||||||
|
if (!(seconds >= 0.0)) return "--";
|
||||||
|
char buf[32];
|
||||||
|
if (seconds < 60.0) {
|
||||||
|
std::snprintf(buf, sizeof(buf), "%.0f s", std::floor(seconds));
|
||||||
|
} else if (seconds < 3600.0) {
|
||||||
|
const int m = static_cast<int>(seconds) / 60;
|
||||||
|
std::snprintf(buf, sizeof(buf), "%dm %02ds", m, static_cast<int>(seconds) % 60);
|
||||||
|
} else {
|
||||||
|
const int h = static_cast<int>(seconds) / 3600;
|
||||||
|
std::snprintf(buf, sizeof(buf), "%dh %02dm", h, (static_cast<int>(seconds) / 60) % 60);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A sample rate in the unit it reads shortest in. Rates here span six decades (a 1 Hz
|
||||||
|
/// housekeeping signal next to a 1 MS/s acquisition), so a fixed unit makes one of the
|
||||||
|
/// two unreadable.
|
||||||
|
std::string format_rate(double per_s) {
|
||||||
|
char buf[32];
|
||||||
|
if (!(per_s > 0.0)) {
|
||||||
|
return "0 S/s";
|
||||||
|
} else if (per_s >= 1.0e6) {
|
||||||
|
std::snprintf(buf, sizeof(buf), "%.3g MS/s", per_s / 1.0e6);
|
||||||
|
} else if (per_s >= 1.0e3) {
|
||||||
|
std::snprintf(buf, sizeof(buf), "%.3g kS/s", per_s / 1.0e3);
|
||||||
|
} else {
|
||||||
|
std::snprintf(buf, sizeof(buf), "%.3g S/s", per_s);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string format_glance(const StatusSnapshot& s) {
|
||||||
|
std::string out = s.state_word;
|
||||||
|
out += " · ";
|
||||||
|
out += s.rate_text.empty() ? "no data" : s.rate_text;
|
||||||
|
out += " · ";
|
||||||
|
out += s.window_text;
|
||||||
|
out += " window";
|
||||||
|
if (s.paused) {
|
||||||
|
// Directly after the state word's own field rather than at the end, because it qualifies
|
||||||
|
// *that* word: "paused · 10 kS/s · 2 s window · for 12 s" reads as one statement.
|
||||||
|
out += " · for " + format_elapsed(s.paused_for_s);
|
||||||
|
}
|
||||||
|
if (s.trigger_on) {
|
||||||
|
out += " · ";
|
||||||
|
if (s.have_event) {
|
||||||
|
out += "event " + format_elapsed(s.since_event_s) + " ago";
|
||||||
|
} else {
|
||||||
|
out += "no event yet";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string format_detail(const StatusSnapshot& s) {
|
||||||
|
char buf[1024];
|
||||||
|
std::snprintf(buf, sizeof(buf),
|
||||||
|
"[%s] %s %s | rate %s | packets %llu frames %llu gaps %llu | %zu signals, "
|
||||||
|
"%zu channels | plot 1: %d trace(s), %zu samples | buffer %.1f MB, %.2f s "
|
||||||
|
"span, time x%llu",
|
||||||
|
s.state_detail.c_str(), s.connected ? "connected" : "waiting",
|
||||||
|
s.have_config ? "configured" : "-",
|
||||||
|
s.rate_text.empty() ? "--" : s.rate_text.c_str(),
|
||||||
|
static_cast<unsigned long long>(s.packets),
|
||||||
|
static_cast<unsigned long long>(s.frames),
|
||||||
|
static_cast<unsigned long long>(s.gaps), s.signals, s.channels,
|
||||||
|
s.plot1_traces, s.plot1_samples, s.buffer_mb, s.span_s,
|
||||||
|
static_cast<unsigned long long>(s.time_stride));
|
||||||
|
std::string out = buf;
|
||||||
|
if (s.paused) out += " | paused for " + format_elapsed(s.paused_for_s);
|
||||||
|
if (s.trigger_on) {
|
||||||
|
out += s.have_event ? " | last event " + format_elapsed(s.since_event_s) + " ago"
|
||||||
|
: " | no event yet";
|
||||||
|
}
|
||||||
|
if (s.reconnects > 0) {
|
||||||
|
char note[48];
|
||||||
|
std::snprintf(note, sizeof(note), " | %d reconnects", s.reconnects);
|
||||||
|
out += note;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
|
||||||
|
/// Everything either tier of the status bar shows, gathered once per frame by the tick so the
|
||||||
|
/// two cannot disagree. No GTK here on purpose: this is the one part of the status bar that
|
||||||
|
/// can be reasoned about as text.
|
||||||
|
struct StatusSnapshot {
|
||||||
|
/// One word from the fixed set: "connecting", "live", "paused", "armed", "capturing",
|
||||||
|
/// "event held", "disarmed". It is a word from a *fixed* set because the bar sits directly
|
||||||
|
/// above the plot and a field whose width tracked the stream would relayout it every frame --
|
||||||
|
/// so a word added here must be no wider than the widest already in it ("connecting" and
|
||||||
|
/// "event held" are both ten characters). "connecting" is the state before a CONFIG has
|
||||||
|
/// arrived, in which there is no signal list and so nothing any other word here can describe.
|
||||||
|
const char* state_word = "live";
|
||||||
|
/// The long prose the old single-line bar carried, kept for the detail string and the dump.
|
||||||
|
std::string state_detail;
|
||||||
|
|
||||||
|
bool connected = false;
|
||||||
|
bool have_config = false;
|
||||||
|
bool trigger_on = false;
|
||||||
|
|
||||||
|
/// Already collapsed to one figure or a range by the caller, without the "rate " prefix.
|
||||||
|
std::string rate_text;
|
||||||
|
double stored_rate_hz = 0.0;
|
||||||
|
double declared_rate_hz = 0.0;
|
||||||
|
|
||||||
|
/// Pre-formatted by ui.cpp's format_window(), which the window control and the Fit notices
|
||||||
|
/// also use, so it stays there rather than moving here.
|
||||||
|
std::string window_text;
|
||||||
|
|
||||||
|
std::uint64_t packets = 0;
|
||||||
|
std::uint64_t frames = 0;
|
||||||
|
std::uint64_t gaps = 0;
|
||||||
|
std::size_t signals = 0;
|
||||||
|
std::size_t channels = 0;
|
||||||
|
int plot1_traces = 0;
|
||||||
|
std::size_t plot1_samples = 0;
|
||||||
|
double buffer_mb = 0.0;
|
||||||
|
double span_s = 0.0;
|
||||||
|
std::uint64_t time_stride = 1;
|
||||||
|
bool have_event = false;
|
||||||
|
double since_event_s = 0.0;
|
||||||
|
int reconnects = 0;
|
||||||
|
|
||||||
|
/// How long the view has been frozen, in **wall** seconds, and whether it is. The producer
|
||||||
|
/// never stops while paused -- it keeps filling the live ring, which is what makes the resume
|
||||||
|
/// seamless -- so the stream's own clock says nothing about how long the user has been looking
|
||||||
|
/// at a still picture. Written through `format_elapsed()` like the event age, for the same
|
||||||
|
/// reason: it steps to a coarser unit instead of growing digits, and the bar is directly above
|
||||||
|
/// the plot.
|
||||||
|
bool paused = false;
|
||||||
|
double paused_for_s = 0.0;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The bar: "live · 10 kS/s · 2 s window", plus " · event 4.2 s ago" in trigger mode, " · for
|
||||||
|
/// 12 s" while paused, and nothing else ever. The badge glyph is a separate widget and is not
|
||||||
|
/// part of this string.
|
||||||
|
std::string format_glance(const StatusSnapshot& s);
|
||||||
|
|
||||||
|
/// The popover's prose and the exit dump's line, field for field as the old single-line bar
|
||||||
|
/// wrote it. CLAUDE.md asserts those fields are read out of the dump, so the field names and
|
||||||
|
/// their order must not change without updating it.
|
||||||
|
std::string format_detail(const StatusSnapshot& s);
|
||||||
|
|
||||||
|
/// A sample rate as "10 kS/s" — `%.3g`, so a trailing zero is dropped and the field's width
|
||||||
|
/// cannot track the stream. Moved here from ui.cpp, which still calls it.
|
||||||
|
std::string format_rate(double hz);
|
||||||
|
|
||||||
|
/// An elapsed time, stepping to a coarser unit rather than growing digits, so its width
|
||||||
|
/// cannot track the stream. Moved here from ui.cpp.
|
||||||
|
std::string format_elapsed(double seconds);
|
||||||
|
|
||||||
|
} // namespace udpscope
|
||||||
+1304
-309
File diff suppressed because it is too large.
Load diff
+115
-31
@@ -25,7 +25,7 @@ MAGIC = 0x53504455
|
|||||||
PKT_DATA, PKT_CONFIG, PKT_ACK, PKT_CONNECT, PKT_DISCONNECT = 0, 1, 2, 3, 4
|
PKT_DATA, PKT_CONFIG, PKT_ACK, PKT_CONNECT, PKT_DISCONNECT = 0, 1, 2, 3, 4
|
||||||
T_FLOAT64 = 9
|
T_FLOAT64 = 9
|
||||||
QUANT_NONE = 0
|
QUANT_NONE = 0
|
||||||
TIME_PACKET, TIME_FULL_ARRAY = 0, 1
|
TIME_PACKET, TIME_FULL_ARRAY, TIME_FIRST_SAMPLE = 0, 1, 2
|
||||||
PUBLISH_ACCUMULATE = 1
|
PUBLISH_ACCUMULATE = 1
|
||||||
NO_TIME_SIGNAL = 0xFFFFFFFF
|
NO_TIME_SIGNAL = 0xFFFFFFFF
|
||||||
|
|
||||||
@@ -50,28 +50,51 @@ def signal_desc(name, *, rate, time_mode, time_idx, unit, rows=1, cols=1, dims=0
|
|||||||
return bytes(d)
|
return bytes(d)
|
||||||
|
|
||||||
|
|
||||||
def build_config(rate):
|
def build_config(rate, time_mode=TIME_FULL_ARRAY, unit="V"):
|
||||||
sigs = [
|
sigs = [
|
||||||
# The stream's clock: referenced by every data signal, so udpscope treats
|
# The stream's clock: referenced by every data signal, so udpscope treats
|
||||||
# it as the time base rather than a plottable channel.
|
# it as the time base rather than a plottable channel. Its *own* time_mode
|
||||||
signal_desc("time", rate=0.0, time_mode=TIME_FULL_ARRAY,
|
# is what udpscope switches on, so `--time-mode first` has to land here.
|
||||||
time_idx=NO_TIME_SIGNAL, unit="s"),
|
signal_desc(
|
||||||
signal_desc("sine", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"),
|
"time", rate=0.0, time_mode=time_mode, time_idx=NO_TIME_SIGNAL, unit="s"
|
||||||
signal_desc("square", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"),
|
),
|
||||||
signal_desc("ramp", rate=rate, time_mode=TIME_FULL_ARRAY, time_idx=0, unit="V"),
|
signal_desc("sine", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
|
||||||
|
signal_desc("square", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
|
||||||
|
signal_desc("ramp", rate=rate, time_mode=time_mode, time_idx=0, unit=unit),
|
||||||
]
|
]
|
||||||
payload = struct.pack("<I", len(sigs)) + b"".join(sigs)
|
payload = struct.pack("<I", len(sigs)) + b"".join(sigs)
|
||||||
payload += bytes([PUBLISH_ACCUMULATE])
|
payload += bytes([PUBLISH_ACCUMULATE])
|
||||||
return header(PKT_CONFIG, 0, len(payload)) + payload
|
return header(PKT_CONFIG, 0, len(payload)) + payload
|
||||||
|
|
||||||
|
|
||||||
def build_data(counter, t0, dt, batch, freq):
|
def build_data(counter, t0, dt, batch, freq, time_mode=TIME_FULL_ARRAY, noise=0.0):
|
||||||
stamps = [t0 + i * dt for i in range(batch)]
|
stamps = [t0 + i * dt for i in range(batch)]
|
||||||
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
|
# The interferer is a tone at 100x the fundamental with a randomly scaled amplitude, which
|
||||||
square = [1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps]
|
# is the shape that is hardest on anything counting crossings: near the sine's own mid level
|
||||||
|
# the fundamental's slope is 2*pi*f while the interferer's is 100x that, so the signal
|
||||||
|
# recrosses the level several times per true crossing. `--noise 0` is the ideal waveform, and
|
||||||
|
# it is the default because the documented probe assertions are *exact* (`peak to peak=2`,
|
||||||
|
# `top=0.636620`) and any interferer moves the extremes.
|
||||||
|
if noise > 0.0:
|
||||||
|
sine = [
|
||||||
|
math.sin(2.0 * math.pi * freq * t)
|
||||||
|
+ random.random() * noise * math.sin(2.0 * math.pi * 100.0 * freq * t)
|
||||||
|
for t in stamps
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
sine = [math.sin(2.0 * math.pi * freq * t) for t in stamps]
|
||||||
|
square = [
|
||||||
|
1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps
|
||||||
|
]
|
||||||
ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]
|
ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]
|
||||||
|
# In FIRST_SAMPLE mode only element 0 of the time signal is meaningful: the producer
|
||||||
|
# stamps the first sample of the batch and says nothing about the rest. A scalar
|
||||||
|
# signal in Accumulate mode still occupies `batch` slots on the wire (the client
|
||||||
|
# reads `num_samples` values for any 1-element signal), so the anchor is repeated --
|
||||||
|
# which is exactly the shape that made udpscope derive a step of zero.
|
||||||
|
wire_time = [t0] * batch if time_mode == TIME_FIRST_SAMPLE else stamps
|
||||||
payload = struct.pack("<QI", int(t0 * 1e9), batch)
|
payload = struct.pack("<QI", int(t0 * 1e9), batch)
|
||||||
for arr in (stamps, sine, square, ramp):
|
for arr in (wire_time, sine, square, ramp):
|
||||||
payload += struct.pack("<%dd" % batch, *arr)
|
payload += struct.pack("<%dd" % batch, *arr)
|
||||||
return header(PKT_DATA, counter, len(payload)) + payload
|
return header(PKT_DATA, counter, len(payload)) + payload
|
||||||
|
|
||||||
@@ -83,29 +106,78 @@ def main():
|
|||||||
ap.add_argument("--batch", type=int, default=100, help="samples per packet")
|
ap.add_argument("--batch", type=int, default=100, help="samples per packet")
|
||||||
ap.add_argument("--freq", type=float, default=5.0, help="waveform frequency, Hz")
|
ap.add_argument("--freq", type=float, default=5.0, help="waveform frequency, Hz")
|
||||||
ap.add_argument("--duration", type=float, default=0.0, help="0 = forever")
|
ap.add_argument("--duration", type=float, default=0.0, help="0 = forever")
|
||||||
ap.add_argument("--stream-scale", type=float, default=1.0,
|
ap.add_argument(
|
||||||
help="stream seconds per wall second: 0.1 mimics a producer whose own "
|
"--stream-scale",
|
||||||
"clock advances ten times slower than the wire delivers samples")
|
type=float,
|
||||||
ap.add_argument("--declared-rate", type=float, default=-1.0,
|
default=1.0,
|
||||||
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'")
|
help="stream seconds per wall second: 0.1 mimics a producer whose own "
|
||||||
ap.add_argument("--clock-jitter", type=float, default=0.0,
|
"clock advances ten times slower than the wire delivers samples",
|
||||||
help="per-packet stamp jitter, as a fraction of one packet's span: a device "
|
)
|
||||||
"that stamps each batch from its own clock does not land on a perfect "
|
ap.add_argument(
|
||||||
"grid, so the stream's timestamps wobble about one. Non-cumulative "
|
"--declared-rate",
|
||||||
"(the mean rate is unchanged); 0 = the ideal grid")
|
type=float,
|
||||||
|
default=-1.0,
|
||||||
|
help="sampling_rate to put in CONFIG; -1 = the real rate, 0 = 'unknown'",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--time-mode",
|
||||||
|
choices=("full", "first"),
|
||||||
|
default="full",
|
||||||
|
help="how the stream carries its clock: 'full' stamps every sample "
|
||||||
|
"(UDPS_TIME_FULL_ARRAY), 'first' stamps only the batch's first "
|
||||||
|
"sample (UDPS_TIME_FIRST_SAMPLE) and leaves the viewer to space the "
|
||||||
|
"rest by the declared cadence -- the shape a real MARTe2-style "
|
||||||
|
"producer with a single u64 clock has",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--unit",
|
||||||
|
default="V",
|
||||||
|
help="the unit the data signals declare in CONFIG; '' declares none, which "
|
||||||
|
"is an ordinary stream (and what every math channel looks like) and is "
|
||||||
|
"the only way a probe can see the unitless readout formatting",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--noise",
|
||||||
|
type=float,
|
||||||
|
default=0.0,
|
||||||
|
help="amplitude of an interferer added to `sine`: a tone at 100x the fundamental, "
|
||||||
|
"scaled by a fresh uniform random factor per sample. 0 = the ideal sine, which is "
|
||||||
|
"what the exact probe assertions (peak to peak = 2, top = 2/pi) are read against; "
|
||||||
|
"0.1 is the figure the measurement hysteresis is sized against",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--clock-jitter",
|
||||||
|
type=float,
|
||||||
|
default=0.0,
|
||||||
|
help="per-packet stamp jitter, as a fraction of one packet's span: a device "
|
||||||
|
"that stamps each batch from its own clock does not land on a perfect "
|
||||||
|
"grid, so the stream's timestamps wobble about one. Non-cumulative "
|
||||||
|
"(the mean rate is unchanged); 0 = the ideal grid",
|
||||||
|
)
|
||||||
args = ap.parse_args()
|
args = ap.parse_args()
|
||||||
|
|
||||||
dt = args.stream_scale / args.rate # stream seconds per sample
|
dt = args.stream_scale / args.rate # stream seconds per sample
|
||||||
period = args.batch / args.rate # wall seconds per packet
|
period = args.batch / args.rate # wall seconds per packet
|
||||||
declared = args.rate if args.declared_rate < 0.0 else args.declared_rate
|
declared = args.rate if args.declared_rate < 0.0 else args.declared_rate
|
||||||
|
time_mode = TIME_FIRST_SAMPLE if args.time_mode == "first" else TIME_FULL_ARRAY
|
||||||
|
|
||||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
sock.bind(("0.0.0.0", args.port))
|
sock.bind(("0.0.0.0", args.port))
|
||||||
sock.settimeout(0.001)
|
sock.settimeout(0.001)
|
||||||
print("mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
|
print(
|
||||||
"stream clock x%.3g, declared %.0f S/s)"
|
"mock udpstreamer: waiting for CONNECT on :%d (%.0f S/s wire, %d per packet, "
|
||||||
% (args.port, args.rate, args.batch, args.stream_scale, declared), flush=True)
|
"stream clock x%.3g, declared %.0f S/s, time-mode %s)"
|
||||||
|
% (
|
||||||
|
args.port,
|
||||||
|
args.rate,
|
||||||
|
args.batch,
|
||||||
|
args.stream_scale,
|
||||||
|
declared,
|
||||||
|
args.time_mode,
|
||||||
|
),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
peer = None
|
peer = None
|
||||||
counter = 1
|
counter = 1
|
||||||
@@ -124,8 +196,11 @@ def main():
|
|||||||
t_stream = 0.0
|
t_stream = 0.0
|
||||||
started = time.monotonic()
|
started = time.monotonic()
|
||||||
next_send = started
|
next_send = started
|
||||||
sock.sendto(build_config(declared), peer)
|
sock.sendto(build_config(declared, time_mode, args.unit), peer)
|
||||||
print("mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer, flush=True)
|
print(
|
||||||
|
"mock udpstreamer: CONNECT from %s:%d, CONFIG sent" % peer,
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
elif magic == MAGIC and ptype == PKT_DISCONNECT:
|
elif magic == MAGIC and ptype == PKT_DISCONNECT:
|
||||||
print("mock udpstreamer: DISCONNECT", flush=True)
|
print("mock udpstreamer: DISCONNECT", flush=True)
|
||||||
peer = None
|
peer = None
|
||||||
@@ -148,11 +223,20 @@ def main():
|
|||||||
pdt = dt
|
pdt = dt
|
||||||
if args.clock_jitter > 0.0:
|
if args.clock_jitter > 0.0:
|
||||||
pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter))
|
pdt = dt * (1.0 + random.uniform(-args.clock_jitter, args.clock_jitter))
|
||||||
sock.sendto(build_data(counter, t_stream, pdt, args.batch, args.freq), peer)
|
sock.sendto(
|
||||||
|
build_data(
|
||||||
|
counter, t_stream, pdt, args.batch, args.freq, time_mode, args.noise
|
||||||
|
),
|
||||||
|
peer,
|
||||||
|
)
|
||||||
counter += 1
|
counter += 1
|
||||||
t_stream += args.batch * pdt
|
t_stream += args.batch * pdt
|
||||||
next_send += period
|
next_send += period
|
||||||
if args.duration > 0.0 and started is not None and now - started > args.duration:
|
if (
|
||||||
|
args.duration > 0.0
|
||||||
|
and started is not None
|
||||||
|
and now - started > args.duration
|
||||||
|
):
|
||||||
print("mock udpstreamer: done (%d packets)" % counter, flush=True)
|
print("mock udpstreamer: done (%d packets)" % counter, flush=True)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user