feat: the manual is a Mallard set opened in Yelp, not a GResource in the binary

The help was 14 Pango-markup `.txt` bodies in a second GResource, drawn into a paged `GtkWindow`
built in `help_window.cpp`. That made F1 a viewer of ours with no search index, no cross-references
and no translation, and it kept the manual in the *binary* when every other desktop application's is
a document on disk. It is now a real Mallard set:

  help/C/org.udpscope.app/  index.page + 14 topic pages + figures/*.png

and `show_help()` hands `help:org.udpscope.app` to whatever the desktop registered for the `help:`
scheme -- Yelp on GNOME -- through a `GtkUriLauncher`, reporting a launch failure with a
`GtkAlertDialog` rather than a click that silently does nothing. The paged window's builder, the
page table and `help_self_check()`'s markup validation are gone with the resource.

The document identifier is the desktop file's own basename (its desktop-file id), derived in
CMakeLists from `data/org.udpscope.app.desktop.in`, so the same string is the `.desktop` name, the
GApplication id and the install directory at once. The set is installed by a new `install()` rule to
`<datadir>/help/C/org.udpscope.app/` -- where the freedesktop Help System Specification's `help:`
URI resolves `help:<id>` -- so the manual is read from a datadir by another process even though the
binary has no install step. `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that
one place.

The 14 pages were migrated fact for fact, but restructured the Mallard way: a `<desc>` per page
(what a search result shows), `<steps>` for procedures, `<terms>` for option lists,
`<gui>`/`<key>`/`<keyseq>` for interface words and keys, `<note>` for the asides, and a topic graph
via `<link type="guide" xref="index#main"/>`. The figures are annotated screenshots produced by
`tools/make-help-shots.sh`, which drives the app headlessly against `tools/mock_udpstreamer.py` and
draws the boxes with ImageMagick; the side panel's box is measured from the app's own `side=NNNx`
dump reading, the header and status bands come from a documented table, and a crop whose standard
deviation is 0 fails the script.

`cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files:
`yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render.
`yelp-check validate` is deliberately absent -- it fetches the Mallard RelaxNG schema over the
network and fails offline for that reason, not for a fault in the pages.

The `[help]` probe reading changes with the resource it was written for. `parsed == pages` over
in-binary markup cannot exist now, so `help_self_check()` scans `UDPSCOPE_HELP_DIR` and the line
reads `topics=14 index=1 figures=3`; `topics=0 index=0` is the manual missing from the tree, which
is the finding the old count named.

Verified: clean build with the documented three warnings (the help bundle's `-Woverlength-strings`
is gone, so the baseline moves from four to three); test-ui 14/14; test-help clean (the four
subcommands and 15 pages rendered); a temp-prefix install lands 15 `.page` files and 3 figures at
`share/help/C/org.udpscope.app/`; the headless run reads `[help] topics=14 index=1 figures=3 dir=...`
with 0 Gtk-CRITICAL. Yelp's own window could not be exercised on this box -- it starts under Xvfb,
activates its portals and never maps, the same WebKit/portal limitation that stops a grab -- so its
rendering and its search index are unverified here; the content pipeline was (`yelp-build` renders
the whole set).

Docs: the manual's build/install/open story in `build-and-run.md` and README; the warning baseline
in `verification.md`/CLAUDE.md; the help bullets in `invariants-gtk-and-chrome.md`; the `[help]`
line in `probe-readings.md`.
This commit is contained in:
Martino Ferrari committed 2026-10-14 16:49:30 +02:00
1 parent 620473a5be
commit c1460a6977
47 files changed
+1910 -1056

No files matched your search

+2 -2
View File
@@ -38,7 +38,7 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
| File | What it covers | | File | What it covers |
| --- | --- | | --- | --- |
| [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. | | [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. |
| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly four build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. | | [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly three build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. |
| [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. | | [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. |
### How the application is put together ### How the application is put together
@@ -60,4 +60,4 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
| [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted with hysteresis. | | [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted with hysteresis. |
| [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. | | [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. |
| [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. | | [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. |
| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the six bundled icons and their authoring rules, and the help pages. | | [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the six bundled icons and their authoring rules, and how the Mallard manual is opened. |
+62 -19
View File
@@ -11,6 +11,8 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
endif() endif()
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
# `CMAKE_INSTALL_DATADIR` (the `share` under the prefix) for the help set's install path.
include(GNUInstallDirs)
# --- libadwaita: the shortcut overview --------------------------------------- # --- libadwaita: the shortcut overview ---------------------------------------
# GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and libadwaita's # GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and libadwaita's
@@ -61,22 +63,35 @@ add_custom_command(
COMMENT "Compiling udpscope icon resources" COMMENT "Compiling udpscope icon resources"
VERBATIM) VERBATIM)
# --- help pages: Pango markup in a GResource ---------------------------------- # --- the manual: a Mallard help set, installed for the `help:` URI ------------
# A second bundle beside the icons'. --c-name is given explicitly so the two generated # The manual is no longer a GResource compiled into the binary. It is a Mallard set that the
# sources cannot collide on symbol names. # desktop help viewer (Yelp on GNOME) reads off disk, because that is the only way `F1` can
set(UDPSCOPE_HELP_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/help) # open a real, searchable, translatable document and the only way the freedesktop Help System
file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS ${UDPSCOPE_HELP_DIR}/*.txt) # Specification's `help:` URI resolves it. The user documentation is then *files*, checked and
add_custom_command( # installed rather than compiled in.
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c #
COMMAND ${GLIB_COMPILE_RESOURCES} # The document identifier -- the `<name>` in `help:<name>` -- is derived from the desktop
--target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c # file's own basename (its desktop-file id), not written out twice: the same string is the
--sourcedir=${UDPSCOPE_HELP_DIR} # `.desktop` file, the GApplication id and the installed directory. The specification installs
--c-name=udpscope_help # a document at `<datadir>/help/<lang>/<document>/` and resolves `help:<document>` to the first
--generate-source # match under $XDG_DATA_HOME/$XDG_DATA_DIRS, so the directory name *is* the identifier.
${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml # NAME_WE is not used: it strips the *longest* extension, so it would cut `org.udpscope.app`
DEPENDS ${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml ${UDPSCOPE_HELP_FILES} # down to `org`. Strip the two suffixes this one file actually carries, in order.
COMMENT "Compiling udpscope help resources" get_filename_component(_udpscope_desktop
VERBATIM) ${CMAKE_CURRENT_SOURCE_DIR}/data/org.udpscope.app.desktop.in NAME)
string(REGEX REPLACE "\\.desktop\\.in$" "" UDPSCOPE_HELP_NAME "${_udpscope_desktop}")
unset(_udpscope_desktop)
set(UDPSCOPE_HELP_SRC ${CMAKE_CURRENT_SOURCE_DIR}/help/C/${UDPSCOPE_HELP_NAME})
file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS
${UDPSCOPE_HELP_SRC}/*.page ${UDPSCOPE_HELP_SRC}/figures/*.png)
# The install rule the specification asks for. `CMAKE_INSTALL_DATADIR` is `share` by default,
# so the set lands at `<prefix>/share/help/C/<document>/`, which is exactly where a viewer
# looks. There is no install step for the binary itself (see the desktop-entry target below),
# but the manual is *read from a datadir* by another process, so it has to be installable.
install(DIRECTORY ${UDPSCOPE_HELP_SRC}/
DESTINATION ${CMAKE_INSTALL_DATADIR}/help/C/${UDPSCOPE_HELP_NAME}
FILES_MATCHING PATTERN "*.page" PATTERN "*.png" PATTERN "*.jpg")
# --- UI files: GtkBuilder XML in a GResource ---------------------------------- # --- UI files: GtkBuilder XML in a GResource ----------------------------------
# The third bundle, for the same reason as the other two: `.ui` files are data, so they are compiled # The third bundle, for the same reason as the other two: `.ui` files are data, so they are compiled
@@ -97,8 +112,9 @@ add_custom_command(
VERBATIM) VERBATIM)
# The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the # The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the
# 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on # 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on
# generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon and # generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon bundle
# help bundles are small enough per file never to need it), so the build stays at its four warnings. # is small enough per file never to need it), so the build stays at its three warnings. The manual is
# no longer compiled in, so the help bundle's own warning is gone with it.
set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings") PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings")
@@ -127,6 +143,28 @@ else()
message(WARNING "python3 was not found: the test-ui target is not available") message(WARNING "python3 was not found: the test-ui target is not available")
endif() endif()
# --- the manual: an opt-in check, never part of `cmake --build` -----------------
# The manual is hand-written Mallard, and `cmake --build` never reads it -- a broken xref or a
# missing figure is a page in the viewer with no content or no image, not a build failure.
# `tools/check_help.sh` runs yelp-check's four offline subcommands over the set (links, media,
# orphans, ids) and renders the whole set to HTML with yelp-build, so the manual is checked the
# way the `.ui` files are. `yelp-check validate` is *not* used: it fetches the Mallard RelaxNG
# schema over the network, which is not available here and would make the check depend on it.
#
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected and
# yelp-tools is not a build dependency.
find_program(UDPSCOPE_YELP_CHECK NAMES yelp-check)
find_program(UDPSCOPE_YELP_BUILD NAMES yelp-build)
if(UDPSCOPE_YELP_CHECK AND UDPSCOPE_YELP_BUILD)
add_custom_target(test-help
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_help.sh ${UDPSCOPE_HELP_SRC}
DEPENDS ${UDPSCOPE_HELP_FILES} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_help.sh
COMMENT "Checking the Mallard help set with yelp-check and yelp-build"
VERBATIM)
else()
message(WARNING "yelp-check/yelp-build were not found: the test-help target is not available")
endif()
# --- build info: version, commit and timestamp compiled into the binary ------- # --- build info: version, commit and timestamp compiled into the binary -------
# Written at configure time so add_executable can name the file, and again on every # Written at configure time so add_executable can name the file, and again on every
# build so the commit and the timestamp belong to the binary that is running rather # build so the commit and the timestamp belong to the binary that is running rather
@@ -148,7 +186,6 @@ set_source_files_properties(${UDPSCOPE_BUILD_INFO} PROPERTIES GENERATED TRUE)
# --- the application ---------------------------------------------------------- # --- the application ----------------------------------------------------------
add_executable(udpscope add_executable(udpscope
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c ${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
${UDPSCOPE_BUILD_INFO} ${UDPSCOPE_BUILD_INFO}
src/main.cpp src/main.cpp
@@ -187,6 +224,12 @@ add_executable(udpscope
) )
target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
# The manual's identifier (`show_help()` builds `help:<id>` from it) and its directory in the
# source tree (what `help_self_check()` scans). Both come from the block near the top, so the
# identifier is the desktop file's basename and nothing is written out twice.
target_compile_definitions(udpscope PRIVATE
UDPSCOPE_HELP_NAME="${UDPSCOPE_HELP_NAME}"
UDPSCOPE_HELP_DIR="${UDPSCOPE_HELP_SRC}")
target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads
PkgConfig::ADWAITA) PkgConfig::ADWAITA)
add_dependencies(udpscope udpscope_build_info) add_dependencies(udpscope udpscope_build_info)
+27 -18
View File
@@ -75,7 +75,7 @@ Every size it prints is read from a value the frame tick captured while the wind
from the widget: the dump runs from the destroy handler, where the widget pointer is already freed 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 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. 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 `UDPSCOPE_PROBE_HELP_MS` builds and destroys the About and shortcut dialogs before the dump, so by then
the memory had been reused and the same two calls **crashed in the middle of the dump**, which 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 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. `gtk_widget_*` call at all and a clean run carries no critical.
@@ -844,9 +844,10 @@ reporting a problem had to retype. And the string it shows is a `std::string`, w
buffer that no one notices until the path matters. 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 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 differs from the shortcut overview's -- `adw_dialog_close()` and `::closed`, rather than
`gtk_window_close()` and `::close-request`. That is the same trap the shortcut overview already `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 had; each keeps a close handler that nulls its own pointer -- the About dialog on `::closed`, the
overview on `::close-request` -- so neither can be presented again after it is gone. What was
deliberately left out: credits (one author, already named), release notes (there are no releases) 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). and an issue-tracker URL (the website link reaches the repository, which carries the tracker).
@@ -1657,11 +1658,12 @@ status bar opens its detail **above** the button: below is off the window's bott
bubble whose body is clipped away while its top edge stays at the button is what "the arrow is 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. detached from the body" looks like.
The **help window** (`F1`) carries the same screenshots as `docs/`: the overview page shows the The **manual** (`F1`, the *Documentation* menu row) opens in the desktop help viewer — Yelp,
main window and a held capture above its text, and the trigger and math pages carry theirs. A through the freedesktop `help:` URI — rather than in a window of udpscope's own: it is a Mallard
page's images are `GtkPicture`s **with a height request** — a shrinkable one asks for no height at set installed under `<datadir>/help/C/org.udpscope.app/`, not a scroll view compiled into the
all inside a box, so without it the page renders its text with a silent gap where the screenshots binary. Its overview page shows the main window and a held capture, and the trigger and math pages
should be. The side column and the two panel toggles stay hidden until a carry their own figures, all produced by `tools/make-help-shots.sh` and embedded as `<figure>`s. 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).
@@ -2332,7 +2334,7 @@ reaches every widget). The full key table:
| `s` | show / hide the side panel | | `s` | show / hide the side panel |
| `f` | fit to data (the plot's own key, not an application accelerator) | | `f` | fit to data (the plot's own key, not an application accelerator) |
| `Ctrl+E` | export visible range | | `Ctrl+E` | export visible range |
| `F1` | open the help window | | `F1` | open the manual (`help:org.udpscope.app`) in the desktop help viewer |
| `Ctrl+/` | open the shortcut overview | | `Ctrl+/` | open the shortcut overview |
| `Ctrl+Q` | quit | | `Ctrl+Q` | quit |
| `+` / `-` | zoom in / out (both axes) | | `+` / `-` | zoom in / out (both axes) |
@@ -2349,15 +2351,22 @@ The six "Plot mouse" rows (wheel, drag, right-click) carry no accelerator key
syntax has no member for pointer gestures — and put the prose in the `subtitle` field instead, syntax has no member for pointer gestures — and put the prose in the `subtitle` field instead,
which `AdwShortcutsItem` renders as free text. which `AdwShortcutsItem` renders as free text.
**The 14-page help window** (overflow menu or F1) is a scrollable text view that opens on the **The manual** (the *Documentation* overflow-menu row, or `F1`) is a Mallard help set opened in
Overview page, so it never comes up blank: the help pages Yelp through the `help:` URI, *not* a scroll view compiled into the binary. `help/C/org.udpscope.app/`
are compiled into the binary as a GResource (`src/help/udpscope_help.gresource.xml`, holds `index.page`, one page per subject, and the annotated screenshots the pages embed; the document
`--c-name=udpscope_help`) for the same reason the icons are — udpscope is run straight out of the identifier `org.udpscope.app` is the desktop file's own basename, so it is the `.desktop` name, the
build tree with no install step, so a search path would have to be found at runtime and a missing GApplication id and the install directory at once. The set is installed by an `install()` rule to
file fails silently. The pages are Pango markup (`.txt` bodies), validated at load; a page that `share/help/C/org.udpscope.app/` — the path the freedesktop Help System Specification's `help:` URI
fails validation renders a one-line stand-in so the window always opens rather than crashing. resolves — and `show_help()` hands `help:org.udpscope.app` to whatever the desktop registered for the
`UDPSCOPE_PROBE_HELP_MS` verifies that `parsed == pages` and that the `closed ok` line carries scheme (Yelp on GNOME), reporting a launch failure with an alert rather than failing silently.
no `Gtk-CRITICAL`. `cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files:
`yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render;
`yelp-check validate` is left out because it fetches the Mallard RelaxNG schema over the network.
`tools/make-help-shots.sh` regenerates the figures by driving the app headlessly against the mock
(GTK4 has to be forced to the Cairo renderer or X11 reads the surface back blank).
`UDPSCOPE_PROBE_HELP_MS` asserts the set is *present in the tree* — its
`[help] topics=… index=… figures=…` reading — rather than parsing pages, since they are files now
and not resources. Mallard is translatable with `itstool`, but only the `C` set is authored here.
**The status bar is two tiers.** The bar itself shows one glance line: the activity badge, the **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`, display state (one word from a fixed set — `connecting`, `live`, `paused`, `armed`, `capturing`,
+22
View File
@@ -52,6 +52,28 @@ here is needed to *build* the application. `blueprint-compiler` and `python3` ar
time; without them the target is absent rather than silently passing. `tools/check_ui.py --lint` also time; without them the target is absent rather than silently passing. `tools/check_ui.py --lint` also
prints the linter's advice, which is advice and deliberately not a gate. prints the linter's advice, which is advice and deliberately not a gate.
The manual has its own check, the same shape:
```sh
cmake --build build --target test-help
```
That runs `yelp-check`'s four offline subcommands over `help/C/<id>/` (links, media, orphans, ids)
and renders the whole set with `yelp-build html` into a temp directory, so the manual is checked the
way the `.ui` files are. **`yelp-check validate` is deliberately not used**: it fetches the Mallard
RelaxNG schema over the network, which is absent on a build box, so it would fail for that reason and
not for a fault in the pages. `yelp-check`/`yelp-build` are found at configure time; without them the
target is absent rather than silently passing.
The manual is a Mallard set under `help/C/<id>/`, installed by an `install()` rule to
`<datadir>/help/C/<id>/` — where the freedesktop Help System Specification's `help:` URI resolves
`help:<id>` — so it is *read from a datadir by another process* even though the binary itself has no
install step. `F1` opens it in Yelp through that URI. The document identifier is the desktop file's
basename (`data/org.udpscope.app.desktop.in` → `org.udpscope.app`), so it is the `.desktop` name, the
GApplication id and the install directory at once. The figures are regenerated by
`tools/make-help-shots.sh`, which drives the app headlessly against the mock and draws the annotation
boxes with ImageMagick; Mallard is translatable with `itstool`, but only the `C` set is authored.
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards), count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards),
+38 -21
View File
@@ -1,6 +1,6 @@
# Invariants: GTK traps, the status bar, the icons and the help # Invariants: GTK traps, the status bar, the icons and the manual
The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and the help pages. The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and how the Mallard manual is installed and opened.
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
@@ -111,19 +111,23 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
tick updates it, so a rebuild is a per-frame relayout of a widget on screen. tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in `update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
the dump is the assertion. the dump is the assertion.
- **The About dialog, the help window and the shortcut overview are opened by `show_about()`, - **The About dialog and the shortcut overview are opened by `show_about()` and `show_shortcuts()`,
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is and each closes with the matching call; the manual is *not* one of these.** `show_help()` hands
exactly **one `GtkWindow`** among the three — the paged help window. It closes with the `help:<document>` URI to the desktop help viewer (`GtkUriLauncher`, i.e. Yelp) — a process of
its own that cannot be closed from here — because the manual is a Mallard set installed for the
freedesktop `help:` URI, not a GResource in this binary (see the help bullet below). So there is
exactly **one `GtkWindow`** among what the app opens: the shortcut overview. It closes with
`gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope `gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope
pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the
math dialog fell into exactly this trap, and the pointer then points at a dead widget until the math dialog fell into exactly this trap, and the pointer then points at a dead widget until the
next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`) next attempt to open the same window. The About dialog (an `AdwAboutDialog`) is an **`AdwDialog`
and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**: and not a `GtkWindow`**: it closes with `adw_dialog_close()` and clears on `::closed`, there being
they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request` no `::close-request` on an `AdwDialog`. It shares one `on_dialog_closed()` handler with the About
on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the pointer, keyed by a **pointer to the pointer** it must null (`&g_about`), which keeps the handler
pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for honest without knowing which dialog it is. One `GtkWidget*` (or `AdwDialog*`) per window, nulled
two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is in its own close handler, is the shape that prevents the double-open defect. `close_help_windows()`
the shape that prevents the double-open defect across all three. (misnamed now) closes just the two dialogs; a failed *launch* is reported by the launcher's own
callback with a `GtkAlertDialog`, so a click that cannot open the manual says so.
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at - **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at
configure time (so `add_executable` can name the file) and again on every build through configure time (so `add_executable` can name the file) and again on every build through
`udpscope_build_info` (so the commit and the timestamp belong to the binary that is running). `udpscope_build_info` (so the commit and the timestamp belong to the binary that is running).
@@ -137,15 +141,28 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special
case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y` case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y`
tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`. tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`.
- **A help page is one `.txt`, one `<file>` line in the GResource manifest, and one `kPages` - **A help page is a Mallard file, and the manual is *not* in the binary.** `help/C/<id>/` holds
row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its `index.page`, one `<topic>.page` per subject, and the `figures/*.png` the pages link, and the
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at whole set is installed (the `install()` rule in CMakeLists) to `<datadir>/help/C/<id>/` — the
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from path the freedesktop Help System Specification's `help:` URI resolves `help:<id>` to. The
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`. document identifier is *derived from the desktop file's basename* (`org.udpscope.app.desktop.in`
A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources). → `org.udpscope.app`), so it is the `.desktop` name, the GApplication id and the install
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height directory at once; `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that one
at all inside a box, so without the request it is allocated nothing and the page renders its text place. `show_help()` opens `help:<id>` with `GtkUriLauncher` (Yelp runs in its own process, so
with a silent gap where its screenshots should be — which is what the first version did. nothing here holds a pointer to it), and its failure callback shows a `GtkAlertDialog`.
`UDPSCOPE_PROBE_HELP_MS` no longer parses pages — they are files, not markup in a resource — so
its `[help]` line asserts the set is *there*: `topics` (the `.page` files other than
`index.page`), `index`, and `figures`, scanned from `UDPSCOPE_HELP_DIR`. `topics=0 index=0` means
the manual is missing from the tree, which is the finding the old `parsed != pages` used to name.
`cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files:
`yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render.
`yelp-check validate` is deliberately absent — it fetches the Mallard RelaxNG schema over the
network and fails offline for that reason, not for a fault in the pages. The figures come from
`tools/make-help-shots.sh`, which drives the app headlessly against the mock and annotates a grab
(GTK4 must be forced to the Cairo renderer, `GSK_RENDERER=cairo`, or the X11 surface reads back
blank). Mallard is translatable with `itstool`, but there is no i18n set-up here: only the `C`
set is authored.
- **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.** - **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.**
Putting the events pane in a paned is what makes its width the user's to drag -- but with the Putting the events pane in a paned is what makes its width the user's to drag -- but with the
position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column
+2 -2
View File
@@ -610,7 +610,7 @@ Two lines of the self-check output are assertions in disguise:
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read. tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
- `[help] pages=14 parsed=14 shortest='Pause and play' 1627 B; shortcuts: app=12 plot=14 tables=26 ui=26 ui_accel=19 ui_icon=7 ui_bare=0; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. The `shortcuts:` numbers are the shortcut overview four ways. - `[help] topics=14 index=1 figures=3 dir=/…/help/C/org.udpscope.app; shortcuts: app=14 plot=14 tables=28 ui=28 ui_accel=21 ui_icon=7 ui_bare=0; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — the manual is a Mallard set on disk now, not a GResource in the binary, so this is a **file-system reading**: `topics` is the `.page` files other than `index.page`, `index` must be 1, and `figures` counts the screenshots under `figures/`. **`topics=0 index=0` means the manual is missing** from `dir` (the source directory the build was configured from, `UDPSCOPE_HELP_DIR`) — the finding the old `parsed != pages` used to name. The manual itself opens in Yelp, another process, which this probe does not and cannot exercise. The `shortcuts:` numbers are the shortcut overview four ways.
`app` and `plot` are the rows in the C tables -- what the application actually *handles* -- and `tables` `app` and `plot` are the rows in the C tables -- what the application actually *handles* -- and `tables`
is their sum. `ui`, `ui_accel`, `ui_icon` and `ui_bare` are what `src/ui/shortcuts.ui` *declares*, read is their sum. `ui`, `ui_accel`, `ui_icon` and `ui_bare` are what `src/ui/shortcuts.ui` *declares*, read
out of the resource with a markup parse rather than off the built widgets, because a tree walk counts out of the resource with a markup parse rather than off the built widgets, because a tree walk counts
@@ -618,7 +618,7 @@ every row twice: `GtkShortcutsWindow` reaches each one from more than one place
**`ui` must equal `tables`** -- two lists of one thing, and a row in one but not the other is a gesture **`ui` must equal `tables`** -- two lists of one thing, and a row in one but not the other is a gesture
that works and is not documented, or is documented and does nothing -- and **`ui_bare` must be zero**: a that works and is not documented, or is documented and does nothing -- and **`ui_bare` must be zero**: a
row with no accelerator and no icon is a cell reading "No Shortcut". `ui_accel + ui_icon == ui`, so those row with no accelerator and no icon is a cell reading "No Shortcut". `ui_accel + ui_icon == ui`, so those
two also catch a row whose accelerator went missing. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape. two also catch a row whose accelerator went missing. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of the two dialogs built, presented and closed in one run as it is a state check: they are closed by **two different calls**, so a mismatched pair crashes or leaks here. One is a `GtkWindow` (the shortcut overview, `gtk_window_close()`/`::close-request`); the About dialog is an `AdwDialog` and closes with `adw_dialog_close()`, cleared on `::closed`. The manual is *not* one of them — it opens in Yelp, a separate process, so there is nothing here to open, read back or close.
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row, - `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B` then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr, and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
+15 -14
View File
@@ -13,21 +13,21 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
./build/udpscope --server 127.0.0.1 --port 8000 ./build/udpscope --server 127.0.0.1 --port 8000
``` ```
Two baselines every run is read against. A clean build emits **four** warnings and no more: two Two baselines every run is read against. A clean build emits **three** warnings and no more: two
`-Wmissing-field-initializers` on `MathNode::source` and `-Woverlength-strings` on **both** `-Wmissing-field-initializers` on `MathNode::source` and one `-Woverlength-strings` on the icons'
generated GResource `.c` files — the help pages' (**516044** bytes) and the icons', whose blob is generated GResource `.c`, whose blob is **12352** against C99's 4095-byte string-literal limit with
**12352** against C99's 4095-byte string-literal limit with the seven action icons and the two app icons the seven action icons and the two app icons that remain (a note about a generated file, which GCC
that remain (a note about a generated file, which GCC itself supports) -- re-measured when itself supports). The *fourth* was the help pages' generated `.c` (**516044** bytes); the manual is
the tab work changed the set (that icon left, the two tab glyphs arrived, and this file's now a Mallard set installed for Yelp rather than a GResource compiled into the binary, so that
help page was rewritten), which did not move the count; re-measure by touching each `*.gresource.xml` bundle — and its warning — is gone, and the baseline moved from four to **three**. Re-measure by
under `src/` and rebuilding. The fifth warning that used to appear here was an unused touching each `*.gresource.xml` under `src/` and rebuilding; a third `-Wmissing-field-initializers`
or any other warning is a finding. (The fifth warning that used to appear here was an unused
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the `output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
`output_after_add` already printed on the same line — and that deletion is what dropped the baseline `output_after_add` already printed on the same line — which dropped the baseline from five to
from five to four. Nothing started reading it. And an exit-dump run emits **no** `Gtk-CRITICAL` at four.) And an exit-dump run emits **no** `Gtk-CRITICAL` at all.
all. A fifth warning, or any critical, is a finding.
An *incremental* build reads **two** of those four: the generated GResource `.c` files are not An *incremental* build reads **one** of those three: the generated GResource `.c` is not recompiled
recompiled when only `src/` changed, so their `-Woverlength-strings` do not reappear. when only `src/` changed, so its `-Woverlength-strings` does not reappear.
A third reading every run offers for free: **how many `[udpscope] buffer allocated:` lines it A third reading every run offers for free: **how many `[udpscope] buffer allocated:` lines it
printed.** The line is not behind a probe — it is in every build — and one line is one *real* change printed.** The line is not behind a probe — it is in every build — and one line is one *real* change
@@ -39,7 +39,8 @@ That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_w
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
the lucky reading of it. A run that had built and destroyed other windows first — the lucky reading of it. A run that had built and destroyed other windows first —
`UDPSCOPE_PROBE_HELP_MS`, which opens three — has had that memory reused, and the same two calls `UDPSCOPE_PROBE_HELP_MS`, which opens the About and shortcut dialogs and then closes them — has had
that memory reused, and the same two calls
then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a
crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the
window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the
+87
View File
@@ -0,0 +1,87 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="cursors">
<info>
<link type="guide" xref="index#analysis"/>
<link type="seealso" xref="measurements"/>
<desc>The shared vertical cursor pair and the per-row horizontal rulers, their four modes,
and how a reading is formatted.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Cursors</title>
<p>Cursors read values off the traces. There are two kinds: one <em>shared vertical
pair</em> that every row carries at the same time, and <em>horizontal rulers</em>, one pair
per row, that belong to that row alone.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Press the cursors button in the toolbar, or <key>c</key>.</p>
</item>
<item>
<p>The pane below the plot shows the readings and owns a <gui>Reset</gui> button.
Drag a marker to move it.</p>
</item>
<item>
<p>The mode dropdown offers four modes: <gui>vertical</gui> (the shared time pair),
<gui>horizontal</gui> (the per-row rulers), <gui>both</gui>, and <gui>mix</gui>.</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The vertical pair is <em>one</em> time for the whole figure, so moving it on
any row moves it on all of them. That is what makes <code>dt</code> meaningful across
rows.</p></item>
<item><p>A ruler belongs to its row and nothing shares it, so two rows' rulers are
independent.</p></item>
<item><p><gui>mix</gui> is the reading mode: one line per <em>bound</em> trace, with the
trace's own values at each marker and their difference. A slot with a name but no
channel behind it is not a trace and gets no line.</p></item>
<item><p>A value is the <em>nearest stored sample's</em>, never a number between two
samples. Zoom in past the sample spacing and the line between two samples is something
the plot drew, not data — so a reading off it would be the drawing. The hover crosshair
reads the same way.</p></item>
<item><p>Every reading carries a <em>metric prefix</em> so the digits sit in
<code>1..999</code>: <code>10.52 mV</code>, not <code>0.01 V</code>. Two decimals is all
an instrument landing on an <code>f32</code> sample can claim, so without the prefix a
small value would read <code>0.00</code> and the reading would be gone. A stream that
declares no unit gets the prefix anyway, written tight against the number
(<code>585.24m</code>) — the space separates the number from its unit, and a spaced
<code>m</code> with nothing after it would read as metres.</p></item>
<item><p>A marker past the newest sample reads <code>--</code>. With only one marker
over data the row reads <code>partial</code>: a difference between a number and nothing
is not a number.</p></item>
<item><p>In <gui>mix</gui> each marker also places a crossing annotation per trace,
which is what lets you see on the plot which sample a number came from. It is <em>on
that sample</em>, so when the marker falls between two it sits a little off its own
line: the line is where you put the marker, the dot is the datum being quoted. The hover
crosshair's mark snaps the same way, and is drawn larger than a trace's sample dots so
it does not read as one more sample.</p></item>
<item><p>The time row exists only in the modes that show the vertical pair.
<gui>horizontal</gui> has no time row, by design.</p></item>
<item><p>A cursor <em>stays where you put it</em> while a live view rolls under it,
which is what a bench scope does: its <code>T0</code> is an age, so a marker left at
<code>-1.5s</code> keeps reading the sample a second and a half old as the stream
advances. A measurement's marks do the opposite — they travel with the sample they
measured — because a cursor is a question you asked and a mark is an answer about one
particular sample.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>Three traces in <gui>mix</gui> mode with both markers over data: 3 lines and 6 crossing
marks, each line reading like <code>sine 0.31 V -0.84 V -1.15 V</code>. Move the second
marker past the newest sample and the third column reads <code>--</code>.</p>
</section>
</page>
+78
View File
@@ -0,0 +1,78 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="export">
<info>
<link type="guide" xref="index#output"/>
<link type="seealso" xref="sessions"/>
<desc>Writing the visible range of every plotted trace to NumPy, CSV or MDF 3.30, and what
each format carries.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Saving and export</title>
<p>Export writes the <em>visible range</em> of every plotted trace to a file: NumPy
<file>.npy</file>, <file>.csv</file>, or <gui>MDF 3.30</gui> (<file>.mf3</file> or
<file>.mdf</file>), chosen by the extension you type.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Pick <gui>Export visible range…</gui> in the overflow menu, or press
<keyseq><key>Ctrl</key><key>E</key></keyseq>.</p>
</item>
<item>
<p>A file chooser asks where; the format follows the path's extension.</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The table is collected at the moment you click, not when the chooser returns. A
live view keeps scrolling while a dialog is up, so a late collection would write a window
you never saw.</p></item>
<item><p>The rows are the <gui>stored samples</gui>, never the LOD buckets the plot draws
from. A row count far below the span times the stored rate means something cut the table
from the pyramid.</p></item>
<item><p>The stored rate is not always the wire rate. A window long enough to need
decimation is stored averaged at a lower rate, and the export carries <em>that</em> —
read the status bar's ⓘ popover for the figure before trusting a sample count.</p></item>
<item><p>Two time columns. <code>time</code> is the display timeline — negative ages
ending at 0 while streaming, offsets from the trigger edge while an event is held — so it
is the x you read a value at on screen. <code>abs_seconds</code> is the source's own
clock for the same sample, which is what lets two exports taken in different modes line
up. Both are strictly increasing and evenly spaced; duplicate or backwards stamps would
mean the file held two clocks.</p></item>
<item><p>An export taken <em>mid-capture</em> writes the partly filled window that is on
screen, which is shorter than the event span. Export outside a capture if you want the
whole event.</p></item>
<item><p><gui>MDF</gui> exists for handing a window to another tool, and its shape follows
the contract that reader uses: one channel group, every channel record-interleaved, the
master channel as the x axis and the unit in the channel metadata. So there the
<em>master is <code>abs_seconds</code></em>, named <code>time</code> — the clock that is
the same in every display mode — and the display axis goes out beside it as an ordinary
channel, <code>time_relative</code>. Both are seconds; each signal carries the source's
own unit. That is also what <gui>ScryLab</gui> reads: channel name → signal name, master
→ x axis, unit → the signal's unit.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
(<code>time</code>, <code>abs_seconds</code>, three signals). The <file>.npy</file> is
<code>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</code> bytes. Read it back with
<code>pd.DataFrame(np.load(path))</code>. The <file>.mf3</file> is bigger — a block per
channel plus a comment — <code>272 + comment + rows·record + 28 + channels·274 + 30</code>
bytes, with <code>record = 16 + 4·cols</code> and <code>channels = cols + 2</code> for the
master and the relative axis. Read that one back with <code>asammdf.MDF(path)</code> or in
ScryLab.</p>
</section>
</page>
Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

+67
View File
@@ -0,0 +1,67 @@
<page xmlns="http://projectmallard.org/1.0/"
type="guide" style="task"
id="index">
<info>
<desc>udpscope plots a live UDPStreamer source as one figure of up to eight stacked
plot rows sharing a single time axis, with a trigger, cursors, measurements, math
channels and an export. This manual covers connecting, the window and its panels,
navigating and pausing the plot, the trigger and its event cache, analysis, and
saving and exporting.</desc>
</info>
<title>udpscope</title>
<section id="main">
<title>Getting started</title>
<list>
<item><p><link xref="overview">Overview</link> — what udpscope is, how it reaches a
source, the window and its panels, and a first five minutes.</p></item>
<item><p><link xref="source">Choosing a source</link> — the command line, the
chooser, and opening an exported waveform instead of a live stream.</p></item>
<item><p><link xref="signals">Signals and calibration</link> — how a signal becomes
a channel, what starts acquisition, and how a scale and offset are applied.</p></item>
<item><p><link xref="layout">Rows, layout and drag &amp; drop</link> — up to eight
rows, four traces each, and the layout editor.</p></item>
</list>
</section>
<section id="viewing">
<title>Viewing</title>
<list>
<item><p><link xref="window">Time window</link> — the one control that changes the
window, and the decimation a long window causes.</p></item>
<item><p><link xref="navigate">Navigating the plot</link> — panning, zooming and
fitting with the mouse and the keyboard.</p></item>
<item><p><link xref="pause">Pause and play</link> — freezing the display without
stopping the stream, and how long you have been looking.</p></item>
<item><p><link xref="trigger">Trigger</link> — the four trigger shapes, arming,
hold-off, the event cache, and searching the buffer.</p></item>
</list>
</section>
<section id="analysis">
<title>Analysis</title>
<list>
<item><p><link xref="cursors">Cursors</link> — the shared vertical pair and the
per-row horizontal rulers.</p></item>
<item><p><link xref="measurements">Measurements</link> — a statistic of the visible
window, its on-plot marks, and its statistics line.</p></item>
<item><p><link xref="math">Math channels</link> — building a derived channel from a
graph of source nodes, operations and one output.</p></item>
</list>
</section>
<section id="output">
<title>Output and state</title>
<list>
<item><p><link xref="export">Saving and export</link> — the visible range to
<file>.npy</file>, <file>.csv</file> or <file>.mf3</file>.</p></item>
<item><p><link xref="sessions">Sessions and settings</link> — the per-source
configuration files and what they carry.</p></item>
<item><p><link xref="status">Status bar and diagnostics</link> — the glance line, the
ⓘ popover, and the activity badge.</p></item>
</list>
</section>
</page>
+71
View File
@@ -0,0 +1,71 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="layout">
<info>
<link type="guide" xref="index#main"/>
<link type="seealso" xref="signals"/>
<desc>How the figure's rows and traces are arranged, how to drop a signal onto a row, and
how the layout editor adds and removes rows.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Rows, layout and drag &amp; drop</title>
<p>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.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Drag a signal's chip out of the side panel (<key>s</key>) and drop it on a row to
add a trace.</p>
</item>
<item>
<p>Press <key>l</key>, or pick <gui>Layout editor</gui> from the overflow menu, to add
and remove rows: a <gui>×</gui> appears at the top left of each row and a <gui>+</gui>
on each boundary between rows, including above the first and below the last.</p>
</item>
<item>
<p>The editor's own button in the top-right corner leaves it.</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>A plot's own <gui>right-click menu</gui> (the same compact rows as the app's
menu) holds the y-axis scale, the two fits, and <gui>Remove signal</gui>, which swaps
the menu to that plot's traces plus <gui>All traces on this plot</gui> — with a
<gui>Back</gui> row to return. That is how a trace comes off a row without going through
the layout editor.</p></item>
<item><p>A drop <em>adds</em> a trace to the row's next free slot; it does not replace
one. With four traces already on a row the drop is refused.</p></item>
<item><p>Inserting a row moves the rows below it <em>with their contents</em>. 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.</p></item>
<item><p>A trace is remembered by the signal's <em>name</em>, not by its channel number,
because a new CONFIG or an enable renumbers the channels and an index would silently
re-point the trace.</p></item>
<item><p>Adding or removing a row rebuilds the figure, which is cheap, but it also
re-deals every trace. A frozen trigger event survives the rebuild: the pinned window is
restated rather than re-framed on the live edge.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>With three traces on row 1, pressing <key>l</key> shows 3 buttons (one <gui>×</gui>,
two <gui>+</gui>). Clicking the <gui>+</gui> above row 1 gives 2 rows and 5 buttons, with
the three traces now on row 2 — the layout probe reports exactly this as
<code>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</code>.</p>
</section>
</page>
+109
View File
@@ -0,0 +1,109 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="math">
<info>
<link type="guide" xref="index#analysis"/>
<link type="seealso" xref="signals"/>
<desc>Building a derived channel from a graph of source nodes, operations and one output,
and how the graph is evaluated.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Math channels</title>
<p>A math channel is a signal computed from other signals. You build it as a small graph —
source nodes, operations, one output — and udpscope then treats the result as an ordinary
channel.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Press the math button in the toolbar, or <key>g</key>.</p>
</item>
<item>
<p>The pane's <gui>+</gui> adds a channel, and opening one shows the graph editor:
nodes as boxes, click a box to select it and edit its parameters beside the canvas.</p>
</item>
<item>
<p>Inside the editor, <gui>add node</gui> is a menu button whose popover picks the op,
so the node arrives as the operation you asked for.</p>
</item>
</steps>
</section>
<section id="gestures">
<title>Graph gestures</title>
<p><gui>drag</gui> from an output port to an input port to wire them; drag a wired input
port off its connection to detach it; click a wire and press <key>Delete</key> to remove
it; drag a box to move it. <gui>Wheel</gui> zooms about the pointer. <gui>Drag the
background</gui> to pan. <key>f</key> fits all nodes into view;
<keyseq><key>Ctrl</key><key>0</key></keyseq> resets the view. <gui>tidy</gui>, the button
beside <gui>fit</gui> in the editor, lays the graph out in topological columns and frames
it. The orange dot marks the output node; click <gui>set as output</gui> to move it.</p>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The graph is evaluated as an ordered list, so a node may only read ones
<em>above</em> it. A wire drawn right to left re-sorts the list silently so the graph
stays valid. A cycle is unrepresentable: the connection is refused rather than created,
and the line beneath the form says why — a loop, or a node asked to read itself.</p></item>
<item><p>A derived channel <em>is</em> a channel. It appears in the signal panel, can be
plotted, measured, exported, and used as a source for another math channel.</p></item>
<item><p>The math runs on the <em>stored</em> samples — decimated and averaged — so a
cutoff you type in Hz means the rate the filter will actually run at. Change the window
across a decimation boundary and the coefficients are rebuilt for the new rate.</p></item>
<item><p>A source may only name an <em>acquired</em> channel. The stream's timestamp
signal is not one: a graph sourced from it validates and then reads NaN for ever, so the
dropdown does not offer it.</p></item>
<item><p>The output defaults to the <em>last</em> node, and a node added to a linear
graph becomes the output. Choosing an earlier node explicitly is respected.</p></item>
<item><p>Every numeric field starts at its own identity value — gain 1, offset 0 — not
at the bottom of its range.</p></item>
</list>
</section>
<section id="why">
<title>Why an operation sometimes does nothing</title>
<p>A graph's <em>output</em> is the node whose value becomes the channel, and it is drawn
as the orange dot. A new graph is built source, then operation, and the last node added
becomes the output — but a graph whose output is left on the source is valid and simply
passes its input through, which is the usual reason a gain or a filter appears to have no
effect. <gui>Set as output</gui> moves the dot.</p>
<p><gui>Apply</gui> is insensitive while the graph is incomplete. A line beneath the form
says why: an input that is not connected, no output chosen, or a <em>channel</em> node
naming a signal the scope is not acquiring. Only the channels being acquired are offered,
because a source that is not acquired reads as no data for ever — and a graph that reads
NaN is harder to diagnose than one that would not save.</p>
<p>The filters are real filters. A math channel's samples are produced as the stream
arrives, once per stored slot, so a low pass carries the history a filter needs. A cutoff
typed in hertz is against the <em>stored</em> rate, which is the wire rate until a wide
window makes the buffer decimate — the status line's rate is that number, and the filters
are rebuilt whenever it changes.</p>
<figure>
<title>The math channel editor: nodes as boxes on a canvas, the orange dot marking the
output, and the selected node's parameters beside it.</title>
<media type="image" mime="image/png" src="figures/math-editor.png" width="938" height="420">
<p>A sine source wired through a gain into a filter, with the output on the last
node.</p>
</media>
</figure>
</section>
<section id="example">
<title>Example</title>
<p><code>[sine] → [gain x2]</code> gives a channel reading <code>y=[-2, 2]</code>. A
100 Hz low pass on the same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band
pass with Q=2 passes 200 Hz at 0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel
must hold exactly as many samples as the shared clock has time slots.</p>
</section>
</page>
+97
View File
@@ -0,0 +1,97 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="measurements">
<info>
<link type="guide" xref="index#analysis"/>
<link type="seealso" xref="cursors"/>
<desc>A measurement is a statistic of the visible window of one signal, with optional
on-plot marks and a per-measurement statistics line.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Measurements</title>
<p>A measurement is a statistic of the <em>visible window</em> of one signal, recomputed as
that window moves. The pane lists them with their values; each can also draw its result on
the plot.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Press the measurements button in the toolbar, or <key>m</key>.</p>
</item>
<item>
<p>The pane's <gui>+</gui> adds one; clicking a row opens the dialog where the signal,
the type, the threshold share and the on-plot marks are set, with <gui>Save</gui> and
<gui>Delete</gui>.</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The types are average, top, bottom, min, max, peak to peak, amplitude, pulse
duration, period and frequency. <em>top</em> and <em>bottom</em> are the means of the
upper and lower halves, which is why a unit sine reads 0.637 (2/π) rather than 1.</p></item>
<item><p>The on-plot marks come in two kinds and the mix is the reading: a <em>level</em>
mark is a ruler at a value, a <em>time</em> mark is a marker at a position. An average
draws one level mark; a max draws a level mark <em>and</em> a time mark at the first
sample that reached it.</p></item>
<item><p>Marks cannot be dragged. A measurement mark says what the measurement measured,
so it is created unselectable — as is the trigger marker.</p></item>
<item><p>A pass is throttled to a cadence rather than run every frame, so a ruler over a
live view is <em>stepped</em>. The number and the ruler come from the same pass, so they
always agree.</p></item>
<item><p>An invalid pass keeps its <em>level</em> marks and drops its <em>time</em>
marks. A level is a property of the signal and outlives the window; a position inside
the window does not. That is what stops the rulers blinking off at a mode change.</p></item>
<item><p>A crossing is counted with <em>hysteresis</em>, not on a bare level: the
thresholds are a tenth of the window's peak to peak either side of the share, and an
edge is counted only once the signal has passed the far one. Period, frequency and pulse
duration all depend on it. The mid level is exactly where a waveform moves fastest, so
any interferer faster than the fundamental recrosses a single level several times per
real crossing — a 5 Hz sine with a 500 Hz tone at a tenth of the amplitude reads 39 Hz
without the band and 5 Hz with it. The band holds to an interferer a third of the
waveform's own size.</p></item>
<item><p>The band is clamped to stay inside the data, which is what keeps a threshold set
near an extreme usable: a fixed band there would sit above every sample and the
measurement would read nothing. A share near an extreme still leaves the band little
room, so it rejects less — which is one reason period and frequency offer no share to
set.</p></item>
<item><p>A window with too few crossings reads <code>invalid</code> / <code>--</code>
rather than a plausible invented number. Frequency over a window holding one edge is the
usual case.</p></item>
<item><p><gui>frequency</gui> is reported in Hz and <gui>period</gui>/<gui>pulse
duration</gui> in seconds; every other type carries the channel's own unit. A value is
written with a <em>metric prefix</em> so its digits sit in <code>1..999</code> —
<code>636.62 mV</code>, not <code>0.64 V</code> — including when the stream declares no
unit at all, which is always the case for a math channel: there the prefix goes tight
against the number (<code>636.62m</code>), since two decimals of an unprefixed small
value is <code>0.00</code>.</p></item>
<item><p>Cost is bounded by reading the summary pyramid the producer already maintains,
not the raw window. The values are still exact: a bucket's sum is a sum.</p></item>
<item><p><gui>Statistics</gui> adds a second line to every measurement showing
<code>n</code>, the mean, the standard deviation and the range. Each observation is one
<em>recompute</em> of the pane, so <code>n</code> counts windows measured, not samples —
a mean frequency over many windows is what that claims to be. <gui>Reset</gui> clears
every accumulator and starts fresh. A change of display window, a CONFIG or a
measurement edit also resets; a pause, a resume, a zoom, a pan and the toggle itself do
not, so turning the line off to read the plot and back on again keeps the history.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>On a 1 Hz ±1 sine: <code>average=0</code>, <code>top=0.636620</code>,
<code>bottom=-0.636620</code>, <code>max=1</code>, <code>min=-1</code>,
<code>peak to peak=2</code>, <code>amplitude=1</code>, <code>pulse duration=0.5 s</code>
(duty 0.5), <code>period=1 s</code>, <code>frequency=1 Hz</code>. Over a 2 M-sample window
those two passes read about 0.1% of the samples.</p>
</section>
</page>
+104
View File
@@ -0,0 +1,104 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="navigate">
<info>
<link type="guide" xref="index#viewing"/>
<link type="seealso" xref="window"/>
<desc>Panning, zooming and fitting the plot with the mouse and the keyboard, and what a
pinned view refuses to do.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Navigating the plot</title>
<p>The plot pans and zooms with the mouse and with the keyboard. Every gesture acts on the
row the pointer is over, and because the rows share one time axis, an x change moves all of
them.</p>
<section id="reach">
<title>How to reach it</title>
<terms>
<item>
<title>Wheel</title>
<p><gui>Wheel</gui> zooms both axes · <gui>Shift</gui>+<gui>Wheel</gui> zooms x only ·
<gui>Ctrl</gui>+<gui>Wheel</gui> zooms y only.</p>
</item>
<item>
<title>Drag</title>
<p><gui>Drag</gui> pans · <gui>Shift</gui>+<gui>Drag</gui> is a box zoom on both axes ·
<gui>Ctrl</gui>+<gui>Drag</gui> on x only · <gui>Alt</gui>+<gui>Drag</gui> on values
only. The area being chosen is framed while you drag it, so you can see what the
release will apply.</p>
</item>
<item>
<title>Keyboard</title>
<p><key>+</key> and <key>-</key> zoom both axes; <key>Ctrl</key> with them restricts
to x, <key>Alt</key> to y. <gui>Arrow keys</gui> pan, <key>f</key> fits,
<key>Escape</key> cancels a gesture in progress.</p>
</item>
</terms>
<p>The full table is in the shortcut overview (<keyseq><key>Ctrl</key><key>/</key></keyseq>).</p>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The keyboard zoom's modifiers are <em>not</em> the wheel's, and that is
deliberate: on common layouts <key>+</key> <em>is</em> <keyseq><key>Shift</key><key>=</key></keyseq>,
so a bare zoom-in already arrives with Shift set and Shift could not mean anything.
Ctrl/Alt mirror the box-zoom drags instead.</p></item>
<item><p>A <em>pinned</em> view is navigable within its own data but cannot leave it.
While an event is held, a capture is filling, or the view is paused, a zoom <em>in</em>
works normally — "not outside the event" is not "not readable" — but a pan or a zoom out
is clamped to the data.</p></item>
<item><p>Nothing you do on the plot changes the <em>time window</em> — only the header
control does. A zoom or a pan moves the view inside the window; <key>f</key> (and
<gui>Fit X axis</gui>) is the <em>undo</em> of a zoom, so a 2 s window zoomed into reads
2 s again. The window is more than the axis: it is also the trigger's event span and how
the samples are stored, so a wheel notch that redefined it would re-plan the buffer and
drop your history.</p></item>
<item><p>The axis does not scroll — the <em>data</em> does. A live axis is a fixed
<code>[-window .. 0]</code> with the newest sample at 0, so the labels stand still and
the trace rolls under them. The cursors stand still with the labels (they are positions
on screen, so a cursor you placed stays where you put it, over newer data each frame),
while a measurement's marks travel with the sample they measured and leave the view when
it does.</p></item>
<item><p>A live view is clamped to the window you asked for, not to what the ring
happens to hold. That is why a buffer that has only just started filling leaves its
left-hand side empty instead of squeezing the window down to the first few
samples.</p></item>
<item><p>Zooming hard into a held capture is exact. The renderer finds the visible
samples by binary search over the real timestamps, never over a nominal sample grid, so
a capture whose clock wandered does not drift away from its axis as you zoom.</p></item>
<item><p>A trace holding far more samples than the plot has pixels is drawn <em>one
pixel column at a time</em>: each column is a single vertical line from the lowest to
the highest value that falls in it. Drawn as a polyline instead, such a window puts
three or four near-vertical lines in every column at slightly different sub-pixel
positions, and how they overlap beats against the pixel grid — which reads as bands of
light and dark that <em>crawl</em> as you pan. One line per column removes the beat, and
it is also much faster: a 1 MS/s view costs a couple of thousand line segments a frame
instead of a couple of hundred thousand.</p></item>
<item><p>Those columns are <em>graded by brightness</em>, the way a digital-phosphor
scope reads: bright where the signal spent its time, faint where it only passed through.
A column's highest and lowest value are the same whether the samples in it are spread
evenly or piled at its two ends, so the grading is what tells the two apart — the flat
parts of a square wave come out solid and the edges between them faint. It is read from
a finer division of each column rather than from the line that is drawn, and it costs
about a millisecond a frame.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>Pause a live view, then press <keyseq><key>Ctrl</key><key>+</key></keyseq>: x narrows
and y does not move. Press <keyseq><key>Alt</key><key>+</key></keyseq>: y narrows and x
does not. Eight doublings into a held 2 s capture, the trace still reaches both margins —
the zoom probe reports <code>gap_px</code> and <code>end_px</code> within a pixel or two
at every step.</p>
</section>
</page>
+169
View File
@@ -0,0 +1,169 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="overview">
<info>
<link type="guide" xref="index#main"/>
<desc>What udpscope plots, the four parts of the window, and one pass through the
application from connecting to saving.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Overview</title>
<section id="what">
<title>What udpscope is</title>
<p>udpscope plots a live UDPStreamer source. One window holds up to eight rows of plots,
each with up to four traces, and every row shares one time axis, so the whole figure
scrolls together. Acquisition runs on its own thread and writes a ring buffer sized from
<cmd>--buffer-mb</cmd>; the drawing thread reads whatever that ring holds when a frame
starts, which is why a trace never makes the window stutter.</p>
<p>The stream is the clock. Every interval you read on screen is measured between
timestamps the source sent, never your machine's, so a stalled source freezes the view
instead of scrolling it — and a source whose clock steps still gets the window you asked
for.</p>
<p>The time axis is <em>relative to now</em>. While streaming it is a fixed
<code>[-window .. 0]</code> with the newest sample at 0, so x reads as an age:
<code>-1.5s</code> is a sample a second and a half old. In trigger mode 0 is the event's
edge instead, and a paused view keeps the ages it was frozen with. The source's own clock
is still there when you need it — an export writes it as a second column.</p>
<figure>
<title>The udpscope window on a live source: the header bar above the plots, the side
panel on the right, and the status bar along the bottom.</title>
<media type="image" mime="image/png" src="figures/main-window.png" width="1280" height="800">
<p>One figure of three rows with four signals enabled, the Measure tab open in the
side panel, and the status bar reading <gui>live</gui>.</p>
</media>
</figure>
</section>
<section id="reach">
<title>How to reach it</title>
<p>Start the app with a source
(<cmd>udpscope --server 127.0.0.1 --port 8000</cmd>), or start it with no
<cmd>--server</cmd> and pick one from the chooser, which lists the sessions you have
opened before. A unicast source serves one client at a time: if a second viewer connects,
they take turns and the status bar says so. To watch one source from several instances,
use <cmd>--multicast</cmd> with the group and <cmd>--interface</cmd> with the local
address.</p>
<p>The signals arrive <em>disabled</em>. Nothing is acquired until you turn one on in the
signal list, and enabling is what starts acquisition — so a freshly opened scope with an
idle side panel is working correctly, not failing to connect.</p>
<note style="tip">
<p>See <link xref="source">Choosing a source</link> for the chooser and the multicast
options in full.</p>
</note>
</section>
<section id="window">
<title>The window</title>
<terms>
<item>
<title>The header bar</title>
<p>Left to right: the time window (<gui>2 s</gui>), the trigger and the pause, the
events, cursors and side-panel switches, and the menu, which holds
<gui>Fit to data</gui>, the <gui>Layout editor</gui>, <gui>Export</gui>,
<gui>Keyboard shortcuts</gui>, <gui>Help</gui> and <gui>About</gui>.</p>
</item>
<item>
<title>The side panel</title>
<p>Two tabs. <gui>Measure</gui> holds the cursors readout and the measurements
derived from it; <gui>Signals</gui> holds the trigger and the channel list, where a
math channel is a row of that one list under a <gui>MATH</gui> heading whose
<gui>+</gui> creates one. One row per channel: its colour, an enable switch, its name
and its unit. Channels are dragged from here onto a plot row.</p>
</item>
<item>
<title>The plot</title>
<p>Up to eight rows, each with its own y axis and up to four traces, all on one shared
time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).</p>
</item>
<item>
<title>The status bar</title>
<p>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 below is what a held capture looks like: <gui>event held</gui>, the
axis pinned to the event, and the trigger marker at the edge.</p>
</item>
</terms>
<figure>
<title>A held capture: the trigger has latched, the state reads
<gui>event held</gui>, and the axis is pinned to the event rather than to the live
edge.</title>
<media type="image" mime="image/png" src="figures/trigger-capture.png" width="1280" height="800">
<p>The trigger marker sits at x = 0 and the pre-trigger share of the window is drawn
to its left.</p>
</media>
</figure>
</section>
<section id="first">
<title>The first five minutes</title>
<steps>
<item>
<p><em>Connect.</em> With no <cmd>--server</cmd>, pick a stored session or type a host
and port.</p>
</item>
<item>
<p><em>Enable a signal</em> in the 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.</p>
</item>
<item>
<p><em>Drag it onto a row.</em> Drop a second signal on another row, or the same
signal again for a second view of it. The colour is per signal, so it is the same one
wherever it appears.</p>
</item>
<item>
<p><em>Set the window</em> with the control in the header bar: type a number, pick a
unit, or step a decade. The trigger's event window is a share of this span, so a
window change moves it.</p>
</item>
<item>
<p><em>Pause</em> (<key>space</key>) to look at a still picture; the producer keeps
filling the buffer behind it, and resuming shows what arrived meanwhile rather than a
hole.</p>
</item>
<item>
<p><em>Arm the trigger</em> (<key>a</key>) and wait for a latch: the view freezes on
one event, and you can zoom in on it. Disarming mid-capture keeps what the capture
got.</p>
</item>
<item>
<p><em>Measure it</em> — the measurements pane computes a statistic of the visible
window, and <gui>show on plot</gui> marks it as a ruler or a pair of markers.</p>
</item>
<item>
<p><em>Save the data</em> with <gui>Export visible range</gui>, which writes the
plotted signals over the window at the stored rate to <file>.npy</file>,
<file>.csv</file> or <file>.mf3</file> (MDF 3.30).</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>A signal nobody enables costs nothing — no buffer, no socket work, no
measurements.</p></item>
<item><p>The window remembers itself. Rows, traces, the time window, calibration, which
signals were enabled, the math channels with their graphs, and the measurements are
written per host and port, and reloaded the next time you open that source.</p></item>
<item><p>A very wide window on a fast stream is drawn from averaged groups of samples,
so the rate in the status bar falls: that is the anti-alias filter, not a fault —
striding would fold a fast signal back as a slow one, which looks entirely plausible on
screen.</p></item>
<item><p>Every instrument the app computed — the trigger marker, the measurement marks
— is locked to the pointer's reach. The cursors are yours to drag; those are not.</p></item>
</list>
</section>
</page>
+61
View File
@@ -0,0 +1,61 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="pause">
<info>
<link type="guide" xref="index#viewing"/>
<link type="seealso" xref="trigger"/>
<desc>Pausing freezes the display without stopping the stream, and the status bar says how
long you have been paused.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Pause and play</title>
<p>Pause freezes the <em>display</em>, not the acquisition. The producer keeps filling the
ring at full rate; the view binds an immutable snapshot of the window that was on screen,
and the axis is pinned to it.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Press the pause button in the toolbar, or <key>Space</key>.</p>
</item>
<item>
<p>Press it again to resume, which rebinds the live ring and puts the window back on
the newest data.</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>Nothing is lost while paused. Because the producer never stops, resuming shows
current data with no hole in the history behind it.</p></item>
<item><p>The axis is pinned to the <em>snapshot's own stamps</em>, not to the window that
was requested. A window reaching back past the oldest retained sample yields a shorter
snapshot, and the pinned axis is that shorter span — so you see a narrower view rather
than an empty margin.</p></item>
<item><p>A paused view is pinned exactly as a held trigger event is: zoom in freely, but
a pan or a zoom out is clamped to the snapshot's data.</p></item>
<item><p>Pausing is the easiest way to measure a layout or read a cursor precisely,
because nothing under the pointer moves between frames.</p></item>
<item><p>The status bar says how long you have been paused — wall time, since the stream
itself never stopped. It is there so a frozen picture cannot be mistaken for a live one
that went quiet.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads
<code>[-1.40 .. 0.00]</code> rather than <code>[-2.00 .. 0.00]</code> with 0.6 s of blank.
The pause probe reports <code>ring_produced</code> still growing across the pause and
<code>event_size</code> non-zero exactly while paused.</p>
</section>
</page>
+93
View File
@@ -0,0 +1,93 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="sessions">
<info>
<link type="guide" xref="index#output"/>
<link type="seealso" xref="source"/>
<desc>Per-source configuration files: where they live, what they persist, and how the
chooser and the Configurations dialog use them.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Sessions and settings</title>
<p>udpscope stores named sessions — <em>configurations</em> — per source, so reopening a
source gives you the scope you closed, and one source can hold several arrangements for
different experiments. There is no global preferences file: the session <em>is</em> the
settings.</p>
<section id="reach">
<title>How to reach it</title>
<p>Nothing to open — the file is written as you work. Its path is in the About dialog, and
the directory is <file>$XDG_CONFIG_HOME/udpscope</file>, or <file>~/.config/udpscope</file>
when that is unset. Each source has a directory <file>&lt;host&gt;_&lt;port&gt;/</file>
holding one <file>&lt;name&gt;.toml</file> per configuration and an <file>active</file>
file naming the one it is on.</p>
</section>
<section id="configs">
<title>Configurations</title>
<p><gui>Configurations…</gui> in the menu lists the running source's configurations: click
a row to open it, <gui>×</gui> to delete it, or type a name and a note and press
<gui>Save this session</gui> to keep the current arrangement under a new one. Opening a
configuration saves the current session first, so switching never loses what it switches
from.</p>
<p>The startup chooser (start with no <cmd>--server</cmd>) lists one row per configuration,
with its note and its settings, so two arrangements of one source are told apart before
they open.</p>
<p>A configuration also carries the settings that can only be decided when the buffers are
allocated: the <gui>buffer size</gui> (<cmd>--buffer-mb</cmd>), relative time, and the
packet capture's ring size. Opening a configuration from the chooser applies all three;
switching inside a running app applies the rest and says in the status line that these take
effect on the next start.</p>
<p>The chooser's form sets the first two for a new host, and its pencil button edits them
on an existing configuration — along with the name and the note. Renaming a configuration,
or editing the address it points at, moves it: the old file is removed rather than left
behind.</p>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>What a configuration persists: the time window, the rows and each row's slot
signals, the calibration, the trace colours, which signals were enabled, the math
channels with their graphs, the measurements pane — and the startup settings: buffer
size, relative time and the packet capture's ring size.</p></item>
<item><p>Signal identity is the <em>name</em> throughout, because a new CONFIG renumbers
the channels.</p></item>
<item><p>A file written by a run against a source whose streamer was down would name no
signals at all, so such a run does <em>not</em> write: a configured session is never
overwritten by a run that configured nothing. (The chooser hides such a file for the same
reason.)</p></item>
<item><p>The multicast group and the interface live <em>inside</em> the file, because its
name cannot carry them. A file from an older build has neither key and loads as unicast
on the default route.</p></item>
<item><p>The chooser lists only configurations that name at least one signal, which is
what keeps it the sessions you configured rather than every port you have ever tested
against.</p></item>
<item><p>Deleting a configuration is a supported way to start over; the chooser's
<gui>×</gui> and the dialog's do exactly that.</p></item>
<item><p>A session from before configurations existed is imported once as the
configuration <code>default</code>, and the old file is kept beside the directory as
<file>&lt;host&gt;_&lt;port&gt;.toml.imported</file>.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>Connect to <code>127.0.0.1:8000</code>, drop three signals on row 1, set a 2 s window,
and save the session as <code>bench</code> with a note.
<file>~/.config/udpscope/127.0.0.1_8000/bench.toml</file> now holds
<code>window_s=2 comment="…"</code> with
<code>signals = ["sine", "ramp", "noise", ""]</code>, and the next run reports
<code>layout loaded from …/127.0.0.1_8000/bench.toml</code> with those signals already
acquired.</p>
</section>
</page>
+70
View File
@@ -0,0 +1,70 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="signals">
<info>
<link type="guide" xref="index#main"/>
<link type="seealso" xref="layout"/>
<desc>How the source's CONFIG becomes channels, what enabling a signal starts, and how a
signal's scale and offset are calibrated.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Signals and calibration</title>
<p>The source's CONFIG message describes its signals: a name, a unit and a type. udpscope
turns each enabled signal into a <em>channel</em> — a ring of values beside the shared time
ring — and a channel is what a trace plots. Calibration is a scale and an offset applied at
ingest.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Open the side panel (<key>s</key>), which lists every signal with a switch.</p>
</item>
<item>
<p>The <gui>Signal list</gui> item in the overflow menu opens the editor where the
unit and the calibration live.</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>Signals start disabled, and enabling one is what starts acquiring it. A trigger
on a signal nobody acquires can never fire, so arming a trigger enables its signal for
you.</p></item>
<item><p><gui>Calibrate</gui> in a signal's row menu opens the gain and offset it is
<em>currently</em> drawn through, so a trace already scaled by a gain of 2 shows 2 —
pressing <gui>Apply</gui> confirms it rather than resetting it. A signal nobody
calibrated shows the identity, 1 and 0.</p></item>
<item><p>Calibration is applied <em>at ingest</em>, so changing it re-plans the buffer
and drops the history. So does enabling or disabling a signal, and so does a new
CONFIG. An empty plot for a second after one of those is the ring refilling, not a
fault.</p></item>
<item><p>The stream's first signal is usually its <em>timestamp</em>, and that one is
never acquired. It cannot be plotted, measured or used as a math source, and udpscope
refuses to put it in a row rather than binding a trace that would read nothing for
ever.</p></item>
<item><p>A signal's colour follows the <em>signal</em>, not the slot, so the same signal
on two rows is one colour and the 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.</p></item>
<item><p><em>Click a coloured chip to change that signal's colour.</em> A grey chip
names no trace, so it is not clickable. The colour is saved with the session;
<gui>Default colour</gui> in the signal's ⋯ menu gives the palette entry back.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>A source declaring <code>time</code>, <code>sine</code>, <code>ramp</code> and
<code>noise</code> yields three plottable signals. Enable all three and the exit dump
reads <code>3 signals, 3 channels</code>; <code>time</code> is not among them.</p>
</section>
</page>
+75
View File
@@ -0,0 +1,75 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="source">
<info>
<link type="guide" xref="index#main"/>
<link type="seealso" xref="sessions"/>
<desc>How udpscope is pointed at a source: the command line, the startup chooser, and
opening an exported waveform instead of a live stream.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Choosing a source</title>
<p>A source is a host and a port, optionally reached through a multicast group on a named
interface. Pass it on the command line, or let udpscope ask.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Run with <cmd>--server</cmd> and the app connects straight away.</p>
</item>
<item>
<p>Run <em>without</em> <cmd>--server</cmd> and a chooser appears first: one row per
stored <em>configuration</em> (<code>host:port · name</code>, its note, and the
settings it carries), newest first, plus a <gui>New host…</gui> form that takes the
address <em>and</em> the configuration it belongs to — its name and note, the
<gui>buffer size</gui>, and whether time is relative or absolute. A row's pencil
button opens the same form on that configuration, pre-filled, and changes to its name
or address move it (the old file is deleted).</p>
</item>
<item>
<p>The second page, <gui>Waveforms</gui>, opens an <em>exported</em> run instead of a
live source: the files opened before (newest first; one that has since been deleted is
marked <em>missing</em>) and an <gui>Open…</gui> button for a new one. The same file
can be opened straight from the command line with <cmd>--open FILE</cmd>. A waveform
is a source that never arrives — no socket, no acquisition, no starvation notice — and
every instrument works on it: cursors, measurements, math channels and another export.
Only <file>.npy</file> files are read.</p>
</item>
</steps>
<p>The chooser runs before any buffer is allocated and before any socket is opened, so
declining it costs nothing.</p>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The list is the sessions that <em>named a signal</em>. A session whose streamer
was down stores empty slot names and is filtered out, which is what keeps the list the
sources you actually configured rather than every port you have ever typed.</p></item>
<item><p>A stored row reconnects the way it was reached. The multicast group and the
interface are written inside the session file, because the file's name
(<file>host_port.toml</file>) cannot carry them.</p></item>
<item><p><cmd>--multicast</cmd> still needs <cmd>--server</cmd>: the CONFIG message is
fetched from the server, not from the group.</p></item>
<item><p>The <gui>×</gui> beside a row deletes that session file. Nothing else is
removed.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p><cmd>udpscope</cmd> with two stored sessions lists
<code>127.0.0.1:8000 plots=1 signals=3</code> and
<code>10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9</code>.
Activating the first reports <code>[sources] picked 127.0.0.1:8000</code> and continues
into the scope with that session's layout already loaded.</p>
</section>
</page>
+72
View File
@@ -0,0 +1,72 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="status">
<info>
<link type="guide" xref="index#output"/>
<desc>The status bar's two tiers, the activity badge, the fixed state words, and the ⓘ
detail popover.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Status bar and diagnostics</title>
<p>The status bar is two tiers. The bar itself is one glance line — the state, the rate and
the window — and everything else is behind the ⓘ button at its right-hand end.</p>
<section id="reach">
<title>How to reach it</title>
<p>The bar is always visible under the plot. Click <gui>ⓘ</gui> for the detail popover.</p>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The badge is one glyph in four colours: green <em>alternating</em> lit and dim
means data is arriving, and a steady colour means it is not. A badge that never blinks is
not a working badge. Of the two steady colours, <em>grey</em> is no stream at all or a
pipeline you stopped (the tooltip says which), and <em>yellow</em> is a source that
answered but has sent no configuration.</p></item>
<item><p>The state field is one word from a fixed set: <code>connecting</code>,
<code>live</code>, <code>paused</code>, <code>armed</code>, <code>capturing</code>,
<code>event held</code>, <code>disarmed</code>. Two states add one field each: in trigger
mode, how long ago the last event was; while paused, how long you have been paused.</p></item>
<item><p><code>connecting</code> means there is no <em>signal list</em> yet — either no
session or a session with no configuration behind it. Every other word claims a signal
list, so until one arrives there is nothing for them to describe: nothing can be enabled
and no data is owed.</p></item>
<item><p>The paused figure is <em>wall</em> time, not stream time — the producer never
stops while paused, so it answers "how long have I been looking at this picture".</p></item>
<item><p>Nothing on the bar may change width with the stream, because the bar sits
directly above the plot and a wider label would relayout it. That is why the rate
collapses to one figure or a range rather than listing per channel, and why an elapsed
time steps to a coarser unit instead of growing digits.</p></item>
<item><p><code>gaps</code> counts packets the receiver knows it missed. It is the number
that matters: a non-zero count means samples are absent from the buffer, not merely
late.</p></item>
<item><p><code>reconnects</code> counts sessions re-established with the source. Over
unicast a climbing count usually means a second client is taking the source's single
session.</p></item>
<item><p>The starvation notice — <em>connected, but no data is arriving</em> — is that
same situation seen from the other side: the signal list is here, so something took the
stream away. A source that has sent <em>no</em> configuration gets a different notice,
<em>trying to connect</em>, because nothing was taken away — pointing the app at a port
where nothing answers is that case, and the usual cause is a streamer that is not running
or is serving another port.</p></item>
<item><p><code>stored</code> versus <code>wire</code> in the popover is the decimation.
They differ exactly when the window is too long to store at full rate.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>The bar reads <code>● live · 10 kS/s · 2 s window</code>. Behind ⓘ:
<code>Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3
channels</code>, <code>Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire
10 kS/s)</code>, <code>Plot 1 3 traces · 195000 samples</code>,
<code>Reconnects 0</code>.</p>
</section>
</page>
+155
View File
@@ -0,0 +1,155 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="trigger">
<info>
<link type="guide" xref="index#viewing"/>
<link type="seealso" xref="pause"/>
<desc>The trigger's four shapes, arming and hold-off, the event cache with its replay,
compare and accumulate, and searching the buffer for a condition.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Trigger</title>
<p>The trigger watches one signal for a condition and captures a window around it. The
<gui>Shape</gui> dropdown chooses what counts as an event; the arming, hold-off, pre/post
window and Normal/Single mode are the same for all four shapes. The captured window is
frozen on screen until you re-arm.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>The trigger button in the toolbar (<key>t</key>) reveals the trigger panel: the
signal, the level, the shape, the edge (rising, falling, either), the mode (Normal or
Single) and the pre-trigger share.</p>
</item>
<item>
<p><gui>Arm</gui> starts watching; <key>a</key> arms and disarms from the keyboard.
When the pane is locked with its <gui>[edit]</gui> button — so that it states what the
trigger is set to instead of showing the controls — <gui>Arm/Disarm</gui> is in its
heading, and the trigger can be armed or disarmed without unfolding the panel. It is
offered only while trigger mode is on, because a trigger that is off has no engine to
arm.</p>
</item>
<item>
<p><gui>Search the buffer</gui> is at the foot of the same panel.</p>
</item>
</steps>
<figure>
<title>A held capture: <gui>event held</gui> in the status bar, the axis pinned to the
event, and the trigger marker at x = 0.</title>
<media type="image" mime="image/png" src="figures/trigger-capture.png" width="1280" height="800">
<p>The pre-trigger share is drawn to the left of the marker.</p>
</media>
</figure>
</section>
<section id="shapes">
<title>The four shapes</title>
<terms>
<item>
<title>Edge</title>
<p>A crossing of the level in the chosen direction, the classic behaviour.</p>
</item>
<item>
<title>Pulse width</title>
<p>A crossing and its return, qualified by how long the excursion lasted: less than,
greater than, within or outside a pair of widths; the event is timed at the closing
edge.</p>
</item>
<item>
<title>Window</title>
<p>The signal entering or leaving a band between two levels; the panel keeps the two
levels at least one increment apart, because a zero-width band is not a band: the
condition becomes an exact float-equality test against the level, which an ordinary
signal never satisfies and a signal sitting at the level satisfies on every
transition.</p>
</item>
<item>
<title>Dropout</title>
<p>The absence of a crossing for longer than a timeout; one event per outage, re-arms
when a crossing returns.</p>
</item>
</terms>
<p>The shape's own rows are the only ones shown; the rest are hidden, so the panel never
offers a pulse width to a window trigger.</p>
</section>
<section id="cache">
<title>The event cache, replay and compare</title>
<p><em>Every triggered acquisition is kept.</em> Latches are written to the event cache
(<file>~/.cache/udpscope/events/&lt;source&gt;/</file>) as a <file>.npy</file> of the
event, a <file>.txt</file> of what it was, and a <file>.bmp</file> picture drawn from the
samples. The <gui>Events</gui> pane on the left — opened from the toolbar's
event-history button, which appears while recording is enabled, and collapsed until you
ask for it — lists the last 32 newest first (time, duration, signals, rate), each row with
its own picture beside the numbers.</p>
<p>Clicking a row <em>replays</em> it: the stored event is loaded exactly like an exported
file, <em>without changing the layout</em> — the rows, the colours, the window, the
cursors and the measurements stay where they are and read the stored data instead. A
replay leaves trigger mode, because a file has no edge to wait for, and the pane stays
open. Drag the pane's edge to resize it. <gui>Live</gui> goes back to the stream; the
status bar names the file until you do.</p>
<p>Select two or more events in the list and the two buttons under the header act on them:
<gui>Compare</gui> puts two events in two plots side by side, keeping the layout you have
— one plot per event, the axes synchronised, so zooming a row in one zooms the same row in
the other — and <gui>Accumulate</gui> overlays up to four of them in the same traces,
translucent, so where they agree the trace is brighter. Your own traces stay where they
are in both. They need events with the same signals and the same length. A single click
selects, a <em>double</em> click replays.</p>
<p><gui>Record events</gui>, in the trigger panel with the trigger's other options, is
what decides whether anything is kept. It is <em>off by default</em> — nothing is written
to disk until you ask — and with it off the history button goes with it.</p>
</section>
<section id="search">
<title>Search the buffer</title>
<p><gui>Search the buffer</gui> runs the same condition over the whole retained ring and
lists every place it holds. The arrows step through hits; each step pauses on the hit,
centred, exactly as the Pause button does. Up to 16 hits in the visible window are marked
on every row; when there are more the panel says how many and suggests zooming in. The
search uses the trigger's own condition, set in one place, so a hit is by construction an
instant the trigger would have fired.</p>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>Arming <em>enables</em> the signal if it was off, because a trigger on a signal
nobody acquires can never fire.</p></item>
<item><p>The pre-trigger share is a share of the <em>display window</em>, not of the
ring. A 2 s window at 50% captures 1 s before the edge and 1 s after.</p></item>
<item><p>There are four display states, and the status bar names each: <gui>armed</gui>
(watching, nothing drawn yet), <gui>capturing</gui> (the edge was accepted and the post
window is filling <em>in view</em>, with the marker at x = 0), <gui>event held</gui>
(the frozen capture) and <gui>disarmed</gui>. A held capture is never quietly replaced
by live data.</p></item>
<item><p>The first edge after arming is only accepted once the history reaches back a
full pre-trigger window, so a capture may legitimately be shorter than you asked for.
The axis is pinned to what the capture actually got.</p></item>
<item><p>Disarming <em>during</em> a capture publishes what the capture got to, so the
picture on screen at that moment stays on screen. No event fired, so the event count
does not move.</p></item>
<item><p>The trigger reads the <em>stored</em> signal — decimated and averaged — not the
wire signal. A spike narrower than one decimation group is filtered away rather than
triggering on data the buffer does not contain.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans
<code>[-1.000 .. 1.000]</code> with <code>n=20001</code> samples at 10 kS/s — the span
times the real rate, plus one — and the marker sits at x = 0 throughout.</p>
</section>
</page>
+66
View File
@@ -0,0 +1,66 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="window">
<info>
<link type="guide" xref="index#viewing"/>
<link type="seealso" xref="navigate"/>
<desc>The time window control: how to set it, and why a long window is decimated rather
than refused.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Time window</title>
<p>The window is how much time the plot shows. Every row shares one window: the figure has
a single x link group, so the rows cannot drift apart. The trigger's event span follows it,
and the pre-trigger share is a share of <em>this</em> window.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>The header bar's window button opens a popover: type a number, pick a unit under
it, or step a decade with the arrows.</p>
</item>
<item>
<p>The button's own label is a reading of the current window in a natural unit,
width-locked so a change of window cannot widen the header bar.</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>A window too long to store at the wire rate is <em>decimated, not refused</em>.
The shared clock takes a power-of-two stride, and each group of samples is
<em>averaged</em> rather than strided, so a tone above the stored Nyquist is attenuated
instead of aliasing to a false low frequency.</p></item>
<item><p>The stamp stored for a group is the group's <em>mean</em>, which is where the
averaging puts the value. Stamping at the end of a group would shift every trace half a
stored sample late.</p></item>
<item><p>Moving the window across a decimation boundary rebuilds the buffer and drops
the history. Walking it inside one decade is free.</p></item>
<item><p><em>This control is the only thing that changes the window.</em> A zoom, a pan
or a fit moves the view <em>inside</em> it and leaves it alone — which is the point of
the previous bullet: a wheel notch that redefined the window could re-plan the buffer
and throw your samples away.</p></item>
<item><p>The trigger sees the <em>stored</em> (filtered) signal, not the wire signal,
because an edge found on a sample that is not in the buffer could not be cut out of
it.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>1 MS/s with 200 MB of buffer. A 2 s window stores every sample
(<code>time x1</code>). Widen it to 600 s and the stride becomes <code>x16</code>: 625 S/s
stored, anti-aliased at 312.5 Hz. A 2 kHz sine seen through that window reads
<code>y=[-0.059, 0.079]</code> — attenuated to about 6%, not folded down to a
full-amplitude 125 Hz one.</p>
</section>
</page>
-79
View File
@@ -1,79 +0,0 @@
<b>What udpscope is</b>
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each with up
to 4 traces, and every row shares one time axis, so the whole figure scrolls together.
Acquisition runs on its own thread and writes a ring buffer sized from <tt>--buffer-mb</tt>;
the drawing thread reads whatever that ring holds when a frame starts, which is why a trace
never makes the window stutter.
The stream is the clock. Every 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, the events, cursors and side-panel switches, and the menu, which holds Fit to data, the
layout editor, Export, Keyboard shortcuts, Help and About.
• <b>The side panel</b> -- two tabs. <b>Measure</b> holds the cursors readout and the
measurements derived from it; <b>Signals</b> holds the trigger and the channel list, where a
math channel is a row of that one list under a <tt>MATH</tt> heading whose <tt>+</tt> creates
one. One row per channel: its colour, an enable switch, its name and its unit. Channels 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>, <tt>.csv</tt> or <tt>.mf3</tt> (MDF 3.30).
<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.
-42
View File
@@ -1,42 +0,0 @@
<b>Choosing a source</b>
A source is a host and a port, optionally reached through a multicast group on a named
interface. Pass it on the command line, or let udpscope ask.
<b>How to reach it</b>
Run with <tt>--server</tt> and the app connects straight away. Run <i>without</i>
<tt>--server</tt> and a chooser appears first: one row per stored <i>configuration</i>
(<tt>host:port · name</tt>, its note, and the settings it carries), newest first, plus a
<b>New host…</b> form that takes the address <i>and</i> the configuration it belongs to — its name
and note, the <b>buffer size</b>, and whether time is relative or absolute. A row's pencil button
opens the same form on that configuration, pre-filled, and changes to its name or address move it
(the old file is deleted).
The second page, <b>Waveforms</b>, opens an <i>exported</i> run instead of a live source: the files
opened before (newest first; one that has since been deleted is marked <i>missing</i>) and an
<b>Open…</b> button for a new one. The same file can be opened straight from the command line with
<tt>--open FILE</tt>. A waveform is a source that never arrives — no socket, no acquisition, no
starvation notice — and every instrument works on it: cursors, measurements, math channels and
another export. Only <tt>.npy</tt> files are read. 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.
-36
View File
@@ -1,36 +0,0 @@
<b>Signals and calibration</b>
The source's CONFIG message describes its signals: a name, a unit and a type. udpscope turns
each enabled signal into a <i>channel</i> — a ring of values beside the shared time ring —
and a channel is what a trace plots. Calibration is a scale and an offset applied at ingest.
<b>How to reach it</b>
The side panel (<tt>s</tt>) lists every signal with a switch. The <b>Signal list</b> item in
the overflow menu opens the editor where the unit and the calibration live.
<b>Worth knowing</b>
• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
nobody acquires can never fire, so arming a trigger enables its signal for you.
• <b>Calibrate</b> in a signal's row menu opens the gain and offset it is <i>currently</i> drawn
through, so a trace already scaled by a gain of 2 shows 2 — pressing <i>Apply</i> confirms it
rather than resetting it. A signal nobody calibrated shows the identity, 1 and 0.
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
for a second after one of those is the ring refilling, not a fault.
• The stream's first signal is usually its <i>timestamp</i>, and that one is never acquired.
It cannot be plotted, measured or used as a math source, and udpscope refuses to put it in
a row rather than binding a trace that would read nothing for ever.
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
is one colour and the side panel's chip identifies it unambiguously. A signal on no row
gets a grey chip rather than none, so the panel's rows keep their height.
• <b>Click a coloured chip to change that signal's colour.</b> A grey chip names no trace, so
it is not clickable. The colour is saved with the session; <b>Default colour</b> in the
signal's ⋯ menu gives the palette entry back.
<b>Example</b>
A source declaring <tt>time</tt>, <tt>sine</tt>, <tt>ramp</tt> and <tt>noise</tt> yields three
plottable signals. Enable all three and the exit dump reads <tt>3 signals, 3 channels</tt>;
<tt>time</tt> is not among them.
-39
View File
@@ -1,39 +0,0 @@
<b>Rows, layout and drag &amp; 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 plot's own <b>right-click menu</b> (the same compact rows as the app's menu) holds the
y-axis scale, the two fits, and <b>Remove signal</b>, which swaps the menu to that plot's traces
plus <i>All traces on this plot</i> — with a <b>Back</b> row to return. That is how a trace comes
off a row without going through the layout editor.
• 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>.
-33
View File
@@ -1,33 +0,0 @@
<b>Time window</b>
The window is how much time the plot shows. Every row shares one window: the figure has a
single x link group, so the rows cannot drift apart. The trigger's event span follows it, and
the pre-trigger share is a share of <i>this</i> window.
<b>How to reach it</b>
The header bar's window button opens a popover: type a number, pick a unit under it, or step a
decade with the arrows. The button's own label is a reading of the current window in a natural
unit, width-locked so a change of window cannot widen the header bar.
<b>Worth knowing</b>
• A window too long to store at the wire rate is <i>decimated, not refused</i>. The shared clock
takes a power-of-two stride, and each group of samples is <b>averaged</b> rather than strided,
so a tone above the stored Nyquist is attenuated instead of aliasing to a false low frequency.
• The stamp stored for a group is the group's <i>mean</i>, which is where the averaging puts the
value. Stamping at the end of a group would shift every trace half a stored sample late.
• Moving the window across a decimation boundary rebuilds the buffer and drops the history.
Walking it inside one decade is free.
• <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.
-59
View File
@@ -1,59 +0,0 @@
<b>Navigating the plot</b>
The plot pans and zooms with the mouse and with the keyboard. Every gesture acts on the row the
pointer is over, and because the rows share one time axis, an x change moves all of them.
<b>How to reach it</b>
<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
<b>Drag</b> pans · <b>Shift+drag</b> is a box zoom on both axes · <b>Ctrl+drag</b> on x only ·
<b>Alt+drag</b> on values only.
The area being chosen is framed while you drag it, so you can see what the release will apply.
<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.
-31
View File
@@ -1,31 +0,0 @@
<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.
-80
View File
@@ -1,80 +0,0 @@
<b>Trigger</b>
The trigger watches one signal for a condition and captures a window around it. The
<b>Shape</b> dropdown chooses what counts as an event; the arming, hold-off, pre/post window
and Normal/Single mode are the same for all four shapes.
The captured window is frozen on screen until you re-arm.
<b>How to reach it</b>
The trigger button in the toolbar (<tt>t</tt>) reveals the trigger panel: the signal, the level,
the shape, 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. When the pane is
locked with its <tt>[edit]</tt> button — so that it states what the trigger is set to instead of showing
the controls — <b>Arm/Disarm</b> is in its heading, and the trigger can be armed or disarmed without
unfolding the panel. It is offered only while trigger mode is on, because a trigger that is off has no
engine to arm.
<b>Search the buffer</b> is at the foot of the same panel.
<b>Worth knowing</b>
• <b>Every triggered acquisition is kept.</b> Latches are written to the event cache
(<tt>~/.cache/udpscope/events/&lt;source&gt;/</tt>) as a <tt>.npy</tt> of the event, a
<tt>.txt</tt> of what it was, and a <tt>.bmp</tt> picture drawn from the samples. The
<b>Events</b> pane on the left -- opened from the toolbar's event-history button, which appears
while recording is enabled, and collapsed until you ask for it -- lists the last 32 newest first
(time, duration, signals, rate), each row with its own picture beside the numbers. Clicking a row
<i>replays</i> it: the stored event is loaded exactly like an exported file, <b>without changing the
layout</b> -- the rows, the colours, the window, the cursors and the measurements stay where they
are and read the stored data instead. A replay leaves trigger mode, because a file has no edge to
wait for, and the pane stays open. Drag the pane's edge to resize it. <b>Live</b> goes back to the
stream; the status bar names the file until you do.
Select two or more events in the list and the two buttons under the header act on them:
<b>Compare</b> puts two events in two plots side by side, keeping the layout you have -- one plot per
event, the axes synchronised, so zooming a row in one zooms the same row in the other -- and
<b>Accumulate</b> overlays up to four of them in the same traces, translucent, so where they agree
the trace is brighter. Your own traces stay where they are in both. They need events with the same
signals and the same length. A single click selects, a <i>double</i> click replays.
<b>Record events</b>, in the trigger panel with the trigger's other options, is what decides whether
anything is kept. It is <b>off by default</b> -- nothing is written to disk until you ask -- and
with it off the history button goes with it.
• 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.
• The four shapes: <b>edge</b> — a crossing of the level in the chosen direction, the classic
behaviour. <b>Pulse width</b> — a crossing and its return, qualified by how long the excursion
lasted: less than, greater than, within or outside a pair of widths; the event is timed at the
closing edge. <b>Window</b> — the signal entering or leaving a band between two levels; the
panel keeps the two levels at least one increment apart, because a zero-width band is not a
band: the condition becomes an exact float-equality test against the level, which an ordinary
signal never satisfies and a signal sitting at the level satisfies on every transition.
<b>Dropout</b> —
the absence of a crossing for longer than a timeout; one event per outage, re-arms when a
crossing returns.
• The shape's own rows are the only ones shown; the rest are hidden, so the panel never offers
a pulse width to a window trigger.
• <b>Search the buffer</b> runs the same condition over the whole retained ring and lists every
place it holds. The arrows step through hits; each step pauses on the hit, centred, exactly as
the Pause button does. Up to 16 hits in the visible window are marked on every row; when there
are more the panel says how many and suggests zooming in. The search uses the trigger's own
condition, set in one place, so a hit is by construction an instant the trigger would have fired.
<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.
-50
View File
@@ -1,50 +0,0 @@
<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>.
-60
View File
@@ -1,60 +0,0 @@
<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>Statistics</b> adds a second line to every measurement showing <tt>n</tt>, the mean, the
standard deviation and the range. Each observation is one <i>recompute</i> of the pane, so
<tt>n</tt> counts windows measured, not samples — a mean frequency over many windows is what
that claims to be. <b>Reset</b> clears every accumulator and starts fresh. A change of display
window, a CONFIG or a measurement edit also resets; a pause, a resume, a zoom, a pan and the
toggle itself do not, so turning the line off to read the plot and back on again keeps the history.
<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.
-61
View File
@@ -1,61 +0,0 @@
<b>Math channels</b>
A math channel is a signal computed from other signals. You build it as a small graph — source
nodes, operations, one output — and udpscope then treats the result as an ordinary channel.
<b>How to reach it</b>
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel, and opening
one shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
the canvas. Inside the editor, <i>add node</i> is a menu button whose popover picks the op, so the
node arrives as the operation you asked for.
<b>Graph gestures:</b> <b>drag</b> from an output port to an input port to wire them; drag a
wired input port off its connection to detach it; click a wire and press <tt>Delete</tt> to remove
it; drag a box to move it. <b>Wheel</b> zooms about the pointer. <b>Drag the background</b> to
pan. <tt>f</tt> fits all nodes into view; <tt>Ctrl+0</tt> resets the view. <i>tidy</i>, the button
beside <i>fit</i> in the editor, lays the graph out in topological columns and frames it. The orange
dot marks the output node; click <i>set as output</i> to move it.
<b>Worth knowing</b>
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
wire drawn right to left re-sorts the list silently so the graph stays valid. A cycle is
unrepresentable: the connection is refused rather than created, and the line beneath the form
says why — a loop, or a node asked to read itself.
• A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted,
measured, exported, and used as a source for another math channel.
• The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
Hz means the rate the filter will actually run at. Change the window across a decimation
boundary and the coefficients are rebuilt for the new rate.
• A source may only name an <i>acquired</i> channel. The stream's timestamp signal is not one:
a graph sourced from it validates and then reads NaN for ever, so the dropdown does not
offer it.
• The output defaults to the <i>last</i> node, and a node added to a linear graph becomes the
output. Choosing an earlier node explicitly is respected.
• Every numeric field starts at its own identity value — gain 1, offset 0 — not at the bottom
of its range.
<b>Example</b>
<tt>[sine] → [gain x2]</tt> gives a channel reading <tt>y=[-2, 2]</tt>. A 100 Hz low pass on the
same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at
0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel must hold exactly as many samples as
the shared clock has time slots.
<b>Why an operation sometimes does nothing</b>. A graph's <i>output</i> is the node whose value
becomes the channel, and it is drawn as the orange dot. A new graph is built source, then operation,
and the last node added becomes the output -- but a graph whose output is left on the source is
valid and simply passes its input through, which is the usual reason a gain or a filter appears to
have no effect. <i>Set as output</i> moves the dot.
<b>Apply is insensitive while the graph is incomplete.</b> A line beneath the form says why:
an input that is not connected, no output chosen, or a <i>channel</i> node naming a signal the
scope is not acquiring. Only the channels being acquired are offered, because a source that is not
acquired reads as no data for ever — and a graph that reads NaN is harder to diagnose than one that
would not save.
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
against the <i>stored</i> rate, which is the wire rate until a wide window makes the buffer decimate
-- the status line's rate is that number, and the filters are rebuilt whenever it changes.
-44
View File
@@ -1,44 +0,0 @@
<b>Saving and export</b>
Export writes the <i>visible range</i> of every plotted trace to a file: NumPy <tt>.npy</tt>,
<tt>.csv</tt>, or <b>MDF 3.30</b> (<tt>.mf3</tt> or <tt>.mdf</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>MDF</b> exists for handing a window to another tool, and its shape follows the contract that
reader uses: one channel group, every channel record-interleaved, the master channel as the x
axis and the unit in the channel metadata. So there the <b>master is <tt>abs_seconds</tt></b>,
named <tt>time</tt> — the clock that is the same in every display mode — and the display axis
goes out beside it as an ordinary channel, <tt>time_relative</tt>. Both are seconds; each signal
carries the source's own unit. That is also what <b>ScryLab</b> reads: channel name → signal
name, master → x axis, unit → the signal's unit.
<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>. The <tt>.mf3</tt> is bigger — a block per channel plus a
comment — <tt>272 + comment + rows·record + 28 + channels·274 + 30</tt> bytes, with
<tt>record = 16 + 4·cols</tt> and <tt>channels = cols + 2</tt> for the master and the relative
axis. Read that one back with <tt>asammdf.MDF(path)</tt> or in ScryLab.
-60
View File
@@ -1,60 +0,0 @@
<b>Sessions and settings</b>
udpscope stores named sessions — <i>configurations</i> — per source, so reopening a source gives
you the scope you closed, and one source can hold several arrangements for different experiments.
There is no global preferences file: the session <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 source has a directory <tt>&lt;host&gt;_&lt;port&gt;/</tt> holding one
<tt>&lt;name&gt;.toml</tt> per configuration and an <tt>active</tt> file naming the one it is on.
<b>Configurations</b>
<b>Configurations…</b> in the menu lists the running source's configurations: click a row to open
it, <tt>×</tt> to delete it, or type a name and a note and press <i>Save this session</i> to keep
the current arrangement under a new one. Opening a configuration saves the current session first,
so switching never loses what it switches from.
The startup chooser (start with no <tt>--server</tt>) lists one row per configuration, with its
note and its settings, so two arrangements of one source are told apart before they open.
A configuration also carries the settings that can only be decided when the buffers are
allocated: the <b>buffer size</b> (<tt>--buffer-mb</tt>), relative time, and the packet capture's
ring size. Opening a configuration from the chooser applies all three; switching inside a running
app applies the rest and says in the status line that these take effect on the next start.
The chooser's form sets the first two for a new host, and its pencil button edits them on an
existing configuration — along with the name and the note. Renaming a configuration, or editing
the address it points at, moves it: the old file is removed rather than left behind.
<b>Worth knowing</b>
• What a configuration persists: the time window, the rows and each row's slot signals, the
calibration, the trace colours, which signals are enabled, the math channels with their graphs,
the measurements pane — and the startup settings: buffer size, relative time and the packet
capture's ring size.
• 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 chooser hides such a file for the same reason.)
• 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 chooser lists only configurations that name at least one signal, which is what keeps it the
sessions you configured rather than every port you have ever tested against.
• Deleting a configuration is a supported way to start over; the chooser's <tt>×</tt> and the
dialog's do exactly that.
• A session from before configurations existed is imported once as the configuration
<tt>default</tt>, and the old file is kept beside the directory as
<tt>&lt;host&gt;_&lt;port&gt;.toml.imported</tt>.
<b>Example</b>
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1, set a 2 s window, and save the
session as <tt>bench</tt> with a note. <tt>~/.config/udpscope/127.0.0.1_8000/bench.toml</tt> now
holds <tt>window_s=2 comment="…"</tt> with <tt>signals = ["sine", "ramp", "noise", ""]</tt>, and
the next run reports <tt>layout loaded from …/127.0.0.1_8000/bench.toml</tt> with those signals
already acquired.
-46
View File
@@ -1,46 +0,0 @@
<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>.
-34
View File
@@ -1,34 +0,0 @@
<?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>
+76 -203
View File
@@ -3,48 +3,34 @@
#include <adwaita.h> #include <adwaita.h>
#include <cstdio>
#include <string> #include <string>
#include "build_info.hpp" #include "build_info.hpp"
#include "layout_store.hpp" #include "layout_store.hpp"
#include "shortcuts.hpp"
// Both come from CMakeLists: the document identifier is derived from the desktop file's
// basename (its desktop-file id), and the directory is where the Mallard set lives in the
// source tree, which is what the self-check scans. Yelp resolves `help:<identifier>` to
// `<datadir>/help/<lang>/<identifier>/` on its own (freedesktop Help System Specification),
// so the identifier and the installed directory have to name the same set -- which is why
// neither is written out again here.
static_assert(sizeof(UDPSCOPE_HELP_NAME) > 1, "UDPSCOPE_HELP_NAME must come from the build");
static_assert(sizeof(UDPSCOPE_HELP_DIR) > 1, "UDPSCOPE_HELP_DIR must come from the build");
namespace udpscope { namespace udpscope {
namespace { namespace {
/// Nulled by each window's own close handler, so a second open cannot present a dead widget. /// Nulled by each dialog's own close handler, so a second open cannot present a dead widget.
/// One pointer per window, file-scope because there is one application. /// One pointer per dialog, file-scope because there is one application. The help manual is no
/// /// longer one of these: it opens in a separate viewer process through the `help:` URI, and a
/// The help window and the shortcut overview are `GtkWindow`s. The About dialog is an /// process with no window of ours has nothing to null.
/// `AdwDialog`, which is **not** a `GtkWindow`: it has no `::close-request`, so it clears on
/// `::closed` and closes with `adw_dialog_close()`.
GtkWidget* g_help = nullptr;
/// The help window is a `.ui` file too, so its builder, the label one page's markup goes into and the
/// column its screenshots go into are all kept beside the window's own pointer. All four are cleared by
/// the window's own close handler: the page switch reads them, and it cannot run after the window is
/// gone.
GtkBuilder* g_help_ui = nullptr;
GtkWidget* g_help_body = nullptr;
GtkWidget* g_help_shots = nullptr;
AdwDialog* g_about = nullptr; AdwDialog* g_about = nullptr;
/// The overview is the one window here built from a `.ui` file, so its builder is held too: the window /// The overview is built from a `.ui` file, so its builder is held too: the window belongs to
/// belongs to the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the /// the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
/// builder is replaced on the next opening rather than reused. /// builder is replaced on the next opening rather than reused.
GtkWidget* g_shortcuts = nullptr; GtkWidget* g_shortcuts = nullptr;
GtkBuilder* g_shortcuts_ui = nullptr; GtkBuilder* g_shortcuts_ui = nullptr;
/// The help window's own close handler, rather than the shared one: this window also owns the label a
/// page is written into and the column its screenshots are built in, and a page switch that ran after
/// the window was gone would read both.
gboolean on_help_close(GtkWindow* window, gpointer) {
(void)window;
g_help = nullptr;
g_help_body = nullptr;
g_help_shots = nullptr;
return FALSE; // let the default handler destroy it
}
gboolean on_window_close(GtkWindow* window, gpointer slot) { gboolean on_window_close(GtkWindow* window, gpointer slot) {
(void)window; (void)window;
*static_cast<GtkWidget**>(slot) = nullptr; *static_cast<GtkWidget**>(slot) = nullptr;
@@ -58,100 +44,27 @@ void on_dialog_closed(AdwDialog* dialog, gpointer slot) {
*static_cast<AdwDialog**>(slot) = nullptr; *static_cast<AdwDialog**>(slot) = nullptr;
} }
struct HelpPage { /// The launch is asynchronous and can fail -- no help viewer installed, no handler for the
const char* section; ///< the group heading in the sidebar /// `help:` scheme, or a document no viewer can find -- and it runs in another process, so
const char* title; ///< the row's label and the page's heading /// nothing raises a window of ours. Report the failure here rather than leaving a click that
const char* resource; ///< a path under /org/udpscope/help /// silently did nothing; the detail names the identifier a viewer looks for.
/// The screenshots shown above the text, as resource paths and a null terminator. A page void on_help_launched(GObject* source, GAsyncResult* result, gpointer user) {
/// with none is the common case and the reason this is a list rather than one field: the GtkWindow* parent = user != nullptr ? GTK_WINDOW(user) : nullptr;
/// 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; GError* error = nullptr;
GBytes* bytes = g_resources_lookup_data(page.resource, G_RESOURCE_LOOKUP_FLAGS_NONE, if (!gtk_uri_launcher_launch_finish(GTK_URI_LAUNCHER(source), result, &error)) {
&error); GtkAlertDialog* alert = gtk_alert_dialog_new("Could not open the help manual");
if (bytes == nullptr) { const std::string detail =
if (error != nullptr) g_error_free(error); std::string(error->message) +
return "<i>This page is missing from the build.</i>"; "\n\nThe manual is installed under $XDG_DATA_DIRS/help/C/help:" UDPSCOPE_HELP_NAME
} "; a desktop help viewer (for example `yelp`) that handles the `help:` URI is needed "
gsize size = 0; "to read it.";
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &size)); gtk_alert_dialog_set_detail(alert, detail.c_str());
std::string body(data, size); gtk_alert_dialog_show(alert, parent);
g_bytes_unref(bytes); g_object_unref(alert);
g_error_free(error);
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) {
if (row == nullptr || g_help_body == 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(g_help_body), body.c_str());
// The screenshots above it, if this page has any. The column comes from the `.ui` by id rather than
// being found as the label's previous sibling: 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* shots = g_help_shots;
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);
} }
// `parent` was referenced for the duration of the async call.
if (parent != nullptr) g_object_unref(parent);
} }
} // namespace } // namespace
@@ -197,84 +110,46 @@ const char* about_icon_name() {
HelpReport help_self_check() { HelpReport help_self_check() {
HelpReport report; HelpReport report;
report.pages = kPageCount;
for (const HelpPage& page : kPages) { // The manual is a set of files on disk now, so the reading is a file-system one: a missing
bool ok = false; // directory leaves `topics=0 index=false`, which is a manual that is not where the build
const std::string body = load_page(page, &ok); // says it is -- and Yelp would show nothing for `help:<identifier>`.
if (ok && !body.empty()) ++report.parsed; GError* error = nullptr;
if (report.shortest_title.empty() || body.size() < report.shortest_bytes) { GDir* dir = g_dir_open(UDPSCOPE_HELP_DIR, 0, &error);
report.shortest_title = page.title; if (dir == nullptr) {
report.shortest_bytes = body.size(); if (error != nullptr) g_error_free(error);
return report;
}
while (const char* name = g_dir_read_name(dir)) {
if (!g_str_has_suffix(name, ".page")) continue;
if (g_strcmp0(name, "index.page") == 0) report.index = true;
else ++report.topics;
}
g_dir_close(dir);
// The figures the pages reference, counted for the same reason: an image that went missing
// is a broken figure in the viewer rather than a failed build.
const std::string figures = std::string(UDPSCOPE_HELP_DIR) + "/figures";
if (GDir* figdir = g_dir_open(figures.c_str(), 0, &error)) {
while (const char* name = g_dir_read_name(figdir)) {
if (g_str_has_suffix(name, ".png") || g_str_has_suffix(name, ".jpg")) ++report.figures;
} }
g_dir_close(figdir);
} else if (error != nullptr) {
g_error_free(error);
} }
return report; return report;
} }
void show_help(GtkWindow* parent) { void show_help(GtkWindow* parent) {
if (g_help != nullptr) { // The freedesktop `help:` URI, handed to whatever the desktop registered for it -- Yelp on
gtk_window_present(GTK_WINDOW(g_help)); // GNOME. The launch is asynchronous, so the parent is referenced across it and released in
return; // the callback; `parent` may legitimately be nullptr (the chooser runs before any window
} // exists).
GtkUriLauncher* launcher = gtk_uri_launcher_new("help:" UDPSCOPE_HELP_NAME);
// A builder per opening, as with every other `.ui` here: the window belongs to its object list and gtk_uri_launcher_launch(launcher, parent, nullptr, on_help_launched,
// `close-request` destroys the window, so the resource is parsed again rather than a dead window parent != nullptr ? g_object_ref(parent) : nullptr);
// fetched. This is where the previous one goes, and it is safe because that window has been closed g_object_unref(launcher);
// for as long as it takes someone to ask for help again.
g_clear_object(&g_help_ui);
g_help_ui = gtk_builder_new_from_resource("/org/udpscope/ui/help.ui");
if (g_help_ui == nullptr) return;
GObject* window = gtk_builder_get_object(g_help_ui, "help_window");
GObject* list = gtk_builder_get_object(g_help_ui, "page_list");
GObject* body = gtk_builder_get_object(g_help_ui, "body");
GObject* shots = gtk_builder_get_object(g_help_ui, "shot_box");
// A missing id is the one mistake the build and `GtkBuilder` both miss; every reader below would be a
// null widget on the first page switch.
if (window == nullptr || list == nullptr || body == nullptr || shots == nullptr) return;
g_help = GTK_WIDGET(window);
g_help_body = GTK_WIDGET(body);
g_help_shots = GTK_WIDGET(shots);
gtk_window_set_transient_for(GTK_WINDOW(g_help), parent);
// The rows are the pages, which is data and not template: a heading row per section, then one row per
// page carrying its own index. A template cannot know the fourteen pages, and a sectioned list is not
// what a GtkListView with one item factory can draw.
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), nullptr);
// 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(g_help, "close-request", G_CALLBACK(on_help_close), &g_help);
gtk_window_present(GTK_WINDOW(g_help));
} }
void show_shortcuts(GtkWindow* parent) { void show_shortcuts(GtkWindow* parent) {
@@ -284,17 +159,16 @@ void show_shortcuts(GtkWindow* parent) {
} }
// A `GtkShortcutsWindow` from `src/ui/shortcuts.ui` -- gedit's arrangement, and GTK's own: one // A `GtkShortcutsWindow` from `src/ui/shortcuts.ui` -- gedit's arrangement, and GTK's own: one
// section per page, with the sidebar and the search box drawn by the window itself. // section per page, with the sidebar and the search box drawn by the window itself. A UI file
// // rather than widget construction because of the mouse rows: an accelerator cannot spell "Ctrl
// A UI file rather than widget construction because of the mouse rows. An accelerator cannot spell // and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement -- has no icon
// "Ctrl and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement for this widget -- // property, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic gesture type
// has no icon property at all, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic // draws the `icon` the row names. (The widget is deprecated since 4.20, but this uses none of
// gesture type draws the `icon` the row names, which is what puts a mouse glyph where the keycaps // its functions: the file is loaded as data and presented as a `GtkWindow`.)
// would be. (The widget is deprecated since 4.20, but this uses none of its functions: the file is
// loaded as data and presented as a `GtkWindow`, so the build stays at its four warnings.)
// //
// A builder per opening, not one reused: the window belongs to the builder and `close-request` // A builder per opening, not one reused: the window belongs to the builder and `close-request`
// destroys the window, so the resource is parsed again rather than an already-dead window fetched. // destroys the window, so the resource is parsed again rather than an already-dead window
// fetched.
g_clear_object(&g_shortcuts_ui); g_clear_object(&g_shortcuts_ui);
g_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui"); g_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui");
if (g_shortcuts_ui == nullptr) return; if (g_shortcuts_ui == nullptr) return;
@@ -307,7 +181,6 @@ void show_shortcuts(GtkWindow* parent) {
} }
void close_help_windows() { 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_about != nullptr) adw_dialog_close(g_about);
if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts)); if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts));
} }
+20 -14
View File
@@ -18,20 +18,26 @@ void show_about(GtkWindow* parent, const std::string& session_path);
/// so the dialog is asserted to *show* the app icon rather than merely to open. /// so the dialog is asserted to *show* the app icon rather than merely to open.
const char* about_icon_name(); const char* about_icon_name();
/// The paged help window. Modal on nothing — it is a reference, so it stays usable while /// Opens the installed Mallard manual in the desktop help viewer — Yelp, through the
/// the scope runs. Already open: presented. /// `help:` URI the freedesktop Help System Specification defines — rather than drawing a
/// window of our own. `parent` is the window the launch is attributed to (and the one an
/// error is reported on); the viewer is a separate process, so nothing here can be closed
/// with `close_help_windows()`.
void show_help(GtkWindow* parent); void show_help(GtkWindow* parent);
/// What UDPSCOPE_PROBE_HELP_MS reports. `parsed` counts pages whose markup /// What UDPSCOPE_PROBE_HELP_MS reports about the manual *in the tree*. `topics` is the
/// pango_parse_markup() accepted and whose body is non-empty; it must equal `pages`. /// number of `<id>.page` files other than `index.page`, and `index` says whether
/// `index.page` is present: the manual is now a set of files on disk (installed by the
/// `install()` rule, and read by Yelp), not a GResource in the binary, so the self-check
/// asserts the set is *there* and must be read as a file-system finding — `topics=0
/// index=0` means the manual is missing from `UDPSCOPE_HELP_DIR`.
struct HelpReport { struct HelpReport {
int pages = 0; int topics = 0;
int parsed = 0; bool index = false;
std::string shortest_title; int figures = 0;
std::size_t shortest_bytes = 0;
}; };
/// Loads and validates every page without building a window. Safe to call headless. /// Scans the manual's directory without opening a viewer. Safe to call headless.
HelpReport help_self_check(); HelpReport help_self_check();
/// The shortcut overview: the application accelerators and the plot's own interactions, /// The shortcut overview: the application accelerators and the plot's own interactions,
@@ -39,11 +45,11 @@ HelpReport help_self_check();
/// deprecated as of GTK 4.18 and this project builds warning-free. /// deprecated as of GTK 4.18 and this project builds warning-free.
void show_shortcuts(GtkWindow* parent); void show_shortcuts(GtkWindow* parent);
/// Closes whichever of the three windows are open. One of them is a GtkWindow -- the help /// Closes whichever of the two dialogs are open. Both are `AdwDialog`s, which are not
/// window, closed with gtk_window_close(), which emits close-request so its handler runs. The /// `GtkWindow`s: they close with `adw_dialog_close()`, which emits `::closed`, and the
/// other two are AdwDialogs, which are not GtkWindows: they close with adw_dialog_close(), /// file-scope pointer is nulled by the handler that prevents a second open presenting a dead
/// which emits ::closed. Either way the file-scope pointer is nulled by the handler, which is /// widget. There is no help window left to close -- the manual opens in a separate viewer
/// what prevents a second open presenting a dead widget. Used by the probe. /// process. Used by the probe.
void close_help_windows(); void close_help_windows();
} // namespace udpscope } // namespace udpscope
+5 -3
View File
@@ -5461,9 +5461,11 @@ void collect_spins(GtkWidget* w, std::vector<double>* out) {
/// checks the GtkEditable refusal. It goes through run_app_action() for the actions and /// checks the GtkEditable refusal. It goes through run_app_action() for the actions and
/// through the real handler for the refusal, because the refusal lives in the handler. /// through the real handler for the refusal, because the refusal lives in the handler.
/// Loads and validates every help page, opens all three windows, closes them, and reports. /// Checks that the manual is present in the tree where the build says it is (`topics` and
/// `parsed` must equal `pages` and no page may be empty; `closed ok` with no GTK critical /// `index`, both zero if it is missing), opens the About and shortcut dialogs, reads them and
/// is the crash check for three windows built and destroyed in one run. /// closes them. The manual itself opens in a separate viewer process, so there is no help
/// window here to build; `closed ok` with no GTK critical is the crash check for the two
/// dialogs built and destroyed in one run.
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq, int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
+3 -2
View File
@@ -2,8 +2,9 @@
<!-- SPDX-License-Identifier: MIT --> <!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari --> <!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- <!--
The application's UI files, compiled into the binary beside the icons and the help pages, so there The application's UI files, compiled into the binary beside the icons, so there is still exactly
is still exactly one file to run. `preprocess="xml-stripblanks"` is what catches a malformed file: one file to run. (The manual is no longer a bundle here: it is a Mallard set installed for Yelp.)
`preprocess="xml-stripblanks"` is what catches a malformed file:
gtk_builder_new_from_resource() on a broken .ui returns nothing usable and the window simply does gtk_builder_new_from_resource() on a broken .ui returns nothing usable and the window simply does
not open, which is a silent failure at runtime and a loud one here. not open, which is a silent failure at runtime and a loud one here.
--> -->
+7 -4
View File
@@ -4447,7 +4447,10 @@ gboolean help_probe(gpointer user) {
const std::string session = const std::string session =
layout_path(a->model->config().server, a->model->config().port); layout_path(a->model->config().server, a->model->config().port);
show_help(GTK_WINDOW(a->window)); // The manual is not opened here: it lives in another process (Yelp, through the `help:` URI)
// and neither it nor this box can draw a window for it, so there is nothing to open, read
// back or close. `help_self_check()` above is the assertion -- the set is on disk where the
// build says it is. The two AdwDialogs below are still built, read and closed for real.
show_about(GTK_WINDOW(a->window), session); show_about(GTK_WINDOW(a->window), session);
show_shortcuts(GTK_WINDOW(a->window)); show_shortcuts(GTK_WINDOW(a->window));
// Read while it is open: the icon is the point of the dialog carrying one, and // Read while it is open: the icon is the point of the dialog carrying one, and
@@ -4464,11 +4467,11 @@ gboolean help_probe(gpointer user) {
// works and is not documented, or is documented and does nothing. // works and is not documented, or is documented and does nothing.
const UiShortcuts ui = ui_shortcut_rows("/org/udpscope/ui/shortcuts.ui"); const UiShortcuts ui = ui_shortcut_rows("/org/udpscope/ui/shortcuts.ui");
std::fprintf(stderr, std::fprintf(stderr,
"[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d tables=%d " "[help] topics=%d index=%d figures=%d dir=%s; shortcuts: app=%d plot=%d tables=%d "
"ui=%d ui_accel=%d ui_icon=%d ui_bare=%d; " "ui=%d ui_accel=%d ui_icon=%d ui_bare=%d; "
"about version=%s commit=%s built=%s settings=%s icon=%s; closed ok\n", "about version=%s commit=%s built=%s settings=%s icon=%s; closed ok\n",
report.pages, report.parsed, report.shortest_title.c_str(), report.topics, report.index ? 1 : 0, report.figures, UDPSCOPE_HELP_DIR,
report.shortest_bytes, accel_count, gesture_count, accel_count + gesture_count, accel_count, gesture_count, accel_count + gesture_count,
ui.rows, ui.with_accel, ui.with_icon, ui.bare, build_info::kVersion, ui.rows, ui.with_accel, ui.with_icon, ui.bare, build_info::kVersion,
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(), build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
about_icon.c_str()); about_icon.c_str());
+33
View File
@@ -0,0 +1,33 @@
#!/bin/sh
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2026 Martino Ferrari
#
# The manual's check, the way tools/check_ui.py is the .ui files' check: it runs the four
# yelp-check subcommands that need no network, then renders the whole set to HTML with
# yelp-build so a page that only fails at the transform step is caught too.
#
# `yelp-check validate` is deliberately absent. It fetches the Mallard RelaxNG schema over
# the network and fails on an offline machine for that reason rather than for a fault in the
# pages -- links/media/orphans/ids are the offline checks.
#
# Exit status is nonzero on the first finding.
set -eu
dir=${1:?usage: check_help.sh HELP_DIR}
if [ ! -f "$dir/index.page" ]; then
echo "check_help: no index.page under $dir" >&2
exit 1
fi
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
for sub in links media orphans ids; do
printf 'yelp-check %s %s\n' "$sub" "$dir"
yelp-check "$sub" "$dir" >/dev/null
done
mkdir -p "$tmp/html"
yelp-build html -o "$tmp/html" "$dir"
pages=$(find "$tmp/html" -maxdepth 1 -name '*.html' | wc -l)
printf 'yelp-build html: %s page(s) rendered\n' "$pages"
+224
View File
@@ -0,0 +1,224 @@
#!/bin/bash
# SPDX-License-Identifier: MIT
# SPDX-FileCopyrightText: 2026 Martino Ferrari
#
# Regenerates the screenshots the Mallard manual carries (help/C/<id>/figures/*.png).
#
# It runs udpscope headlessly under its own Xvfb, against tools/mock_udpstreamer.py, drives it
# into a state with the same `UDPSCOPE_*` probes the self-check uses, grabs the window with
# ImageMagick's `import`, and draws numbered annotation boxes on a borderless crop with
# `magick`.
#
# Two things about a headless box, both measured rather than assumed:
#
# * GTK4 picks the Vulkan renderer by default, and under Xvfb that surface is not readable
# back through X11 -- `import` sees a window with no pixels (standard deviation 0). Forcing
# the Cairo renderer (`GSK_RENDERER=cairo`, with `LIBGL_ALWAYS_SOFTWARE=1`) makes GTK paint
# with XPutImage, which `import` can read.
# * `xdotool search --name udpscope` finds nothing: GTK4 sets no WM_NAME on the X window it
# creates, so windows are found by their geometry, not their name.
#
# Box provenance: the sidebar width is read from the app's own probe dump (`side=NNNx`), so the
# side-panel box tracks the real widget; the header and status bands are the table in
# `window_boxes` below, each named where it is used, because the dump reports neither.
#
# Usage: tools/make-help-shots.sh [OUTPUT_DIR] (default help/C/<id>/figures)
set -euo pipefail
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
root=$(dirname -- "$here")
app=${UDPSCOPE_SHOTS_APP:-$root/build/udpscope}
mock=$here/mock_udpstreamer.py
help_id=org.udpscope.app
out=${1:-$root/help/C/$help_id/figures}
port=18391
for tool in Xvfb import magick xdotool python3; do
command -v "$tool" >/dev/null || { echo "make-help-shots: $tool is required" >&2; exit 1; }
done
[ -x "$app" ] || { echo "make-help-shots: $app is missing; build it first" >&2; exit 1; }
[ -f "$mock" ] || { echo "make-help-shots: $mock is missing" >&2; exit 1; }
mkdir -p "$out"
# --- the window's own geometry, and the boxes it is annotated with -------------
# main-window.ui asks for 1280x800, and the sidebar is a fixed 320 px
# (sidebar-width-unit=px, min=max=320). The header bar is the window's titlebar and the status
# row sits at the foot of the plot column; neither reports a pixel height, so their bands are
# the third measurement -- a pixel scan of a grab, 51 px and 43 px on the current theme -- and
# are refreshed by re-measuring if the styling changes.
WIN_W=1280
WIN_H=800
HEADER_H=51
STATUS_H=43
PLOT_W=952
boxes_main="header bar:0:0:$WIN_W:$HEADER_H
side panel:SIDE_X:$HEADER_H:SIDE_W:706
plot:0:$HEADER_H:$PLOT_W:706
status bar:0:757:$PLOT_W:$STATUS_H"
boxes_trigger="event held, axis pinned to the event:0:757:$PLOT_W:$STATUS_H
the trigger marker at x = 0:0:$HEADER_H:$PLOT_W:706"
boxes_math="the graph editor:8:8:EW-24:EH-24"
display=
xvfb_pid=
app_pid=
mock_pid=
cleanup() {
[ -n "$app_pid" ] && kill "$app_pid" 2>/dev/null || true
[ -n "$mock_pid" ] && kill "$mock_pid" 2>/dev/null || true
[ -n "$xvfb_pid" ] && kill "$xvfb_pid" 2>/dev/null || true
}
trap cleanup EXIT
# A display of our own, so nothing on the developer's screen is grabbed or disturbed.
for n in $(seq 210 240); do
if Xvfb ":$n" -screen 0 1600x1000x24 -nolisten tcp >/dev/null 2>&1 & then
xvfb_pid=$!
sleep 1
if kill -0 "$xvfb_pid" 2>/dev/null; then display=":$n"; break; fi
fi
done
[ -n "$display" ] || { echo "make-help-shots: no free Xvfb display in :210..:240" >&2; exit 1; }
export DISPLAY="$display"
export GDK_BACKEND=x11 GSK_RENDERER=cairo LIBGL_ALWAYS_SOFTWARE=1
# A throwaway config dir: the app restores the last session for a source, and a restored
# layout would change the very arrangement the screenshot is meant to show.
tmp=$(mktemp -d)
trap 'cleanup; rm -rf "$tmp"' EXIT
export XDG_CONFIG_HOME="$tmp/config" XDG_CACHE_HOME="$tmp/cache" XDG_DATA_HOME="$tmp/data"
python3 "$mock" --port "$port" --rate 10000 --batch 100 >"$tmp/mock.log" 2>&1 &
mock_pid=$!
sleep 0.5
# The largest window that is not the 1600x1000 X root; that is the app's 1280x800 one (a math
# editor opened on top is smaller, and X may also expose unnamed GTK helper windows).
pick_window() {
local id geo w best= best_w=0
for id in $(xdotool search --onlyvisible "" 2>/dev/null); do
geo=$(xdotool getwindowgeometry "$id" 2>/dev/null | awk '/Geometry/{print $2}')
[ "$geo" = "1600x1000" ] && continue
w=${geo%x*}
case "$w" in ''|*[!0-9]*) continue;; esac
if [ "$w" -gt "$best_w" ]; then best=$id; best_w=$w; fi
done
[ -n "$best" ] && echo "$best"
}
# The math editor is a window of its own (a `GtkWindow`, presented on the main one), so it is
# the largest window that is neither the X root nor the app's 1280x800 one and that has a real
# size (X exposes 1x1 helper windows too).
pick_other_window() {
local id geo w best= best_w=0
for id in $(xdotool search --onlyvisible "" 2>/dev/null); do
geo=$(xdotool getwindowgeometry "$id" 2>/dev/null | awk '/Geometry/{print $2}')
case "$geo" in 1600x1000|"${WIN_W}x${WIN_H}") continue;; esac
w=${geo%x*}
case "$w" in ''|*[!0-9]*) continue;; esac
[ "$w" -le 50 ] && continue
if [ "$w" -gt "$best_w" ]; then best=$id; best_w=$w; fi
done
[ -n "$best" ] && echo "$best"
}
start_app() { # LOGFILE ENVS... ; extra args via APP_ARGS
local log=$1; shift
# shellcheck disable=SC2086
env "$@" "$app" --server 127.0.0.1 --port "$port" --buffer-mb 16 ${APP_ARGS:-} >"$log" 2>&1 &
app_pid=$!
}
stop_app() {
[ -n "$app_pid" ] && kill "$app_pid" 2>/dev/null || true
wait "$app_pid" 2>/dev/null || true
app_pid=
}
side_of_dump() { # LOGFILE -> the sidebar width the app reports, else the table's 320
local s
s=$(grep -o 'side=[0-9]*x' "$1" 2>/dev/null | head -1 | grep -o '[0-9]*' || true)
echo "${s:-320}"
}
# annotate NAME RAW LOG BOXES W H -> the figure, with a numbered box per line of BOXES.
# A box is `label:x:y:w:h`; `SIDE_X`/`SIDE_W` are the sidebar's own numbers, `EW`/`EH` the
# grabbed window's width and height.
annotate() {
local name=$1 raw=$2 log=$3 boxes=$4 win_w=$5 win_h=$6
local side_w side_x i=0 draw=() line label x y w h
side_w=$(side_of_dump "$log")
side_x=$((win_w - side_w))
while IFS= read -r line; do
[ -n "$line" ] || continue
IFS=: read -r label x y w h <<<"$line"
x=${x/SIDE_X/$side_x}; w=${w/SIDE_W/$side_w}
w=${w/EW/$win_w}; h=${h/EH/$win_h}
i=$((i + 1))
draw+=(-stroke '#e01b24' -strokewidth 3 -fill none
-draw "rectangle $x,$y $((x + w)),$((y + h))"
-stroke none -fill '#e01b24'
-draw "circle $((x + 17)),$((y + 17)) $((x + 17)),$((y + 33))"
-fill white -stroke none -pointsize 22
-draw "text $((x + 10)),$((y + 25)) '$i'")
done <<<"$boxes"
magick "$raw" -crop "${win_w}x${win_h}+0+0" +repage "${draw[@]}" "$out/$name.png"
local sd
sd=$(magick identify -format '%[standard-deviation]' "$out/$name.png")
awk -v s="$sd" 'BEGIN{exit !(s+0 > 0)}' ||
{ echo "make-help-shots: the '$name' figure is blank (stddev=$sd)" >&2; return 1; }
echo "make-help-shots: wrote $out/$name.png ($(magick identify -format '%wx%h' "$out/$name.png"))"
# Print what each number marks, so the script is self-documenting in CI.
local n=0
while IFS= read -r line; do
[ -n "$line" ] || continue
n=$((n + 1))
printf ' %s = %s\n' "$n" "${line%%:*}"
done <<<"$boxes"
}
# grab NAME LOG BOXES [PICKER] -- grab the picked window, fail if it is blank, stop the app,
# annotate it at its own size.
grab() {
local name=$1 log=$2 boxes=$3 picker=${4:-pick_window} win geo win_w win_h raw sd
win=$($picker)
[ -n "$win" ] || { echo "make-help-shots: no window for '$name'" >&2; stop_app; return 1; }
geo=$(xdotool getwindowgeometry "$win" 2>/dev/null | awk '/Geometry/{print $2}')
win_w=${geo%x*}; win_h=${geo#*x}
raw="$tmp/$name.raw.png"
import -window "$win" "$raw"
sd=$(magick identify -format '%[standard-deviation]' "$raw")
awk -v s="$sd" 'BEGIN{exit !(s+0 > 0)}' ||
{ echo "make-help-shots: '$name' grabbed a blank window (stddev=$sd)" >&2; stop_app; return 1; }
stop_app
annotate "$name" "$raw" "$log" "$boxes" "$win_w" "$win_h"
}
# --- scene 1: the live window -------------------------------------------------
echo "make-help-shots: scene 'main-window'"
APP_ARGS="--plots 3" start_app "$tmp/main-window.log" UDPSCOPE_ENABLE_ALL_MS=2500
sleep 8
grab main-window "$tmp/main-window.log" "$boxes_main"
# --- scene 2: a held capture --------------------------------------------------
echo "make-help-shots: scene 'trigger-capture'"
APP_ARGS="--plots 2" start_app "$tmp/trigger-capture.log" \
UDPSCOPE_ENABLE_ALL_MS=1200 UDPSCOPE_ARM_TRIGGER_MS=1500
sleep 9
grab trigger-capture "$tmp/trigger-capture.log" "$boxes_trigger"
# --- scene 3: the math editor -------------------------------------------------
# The editor is a window of its own, presented on the main one, and `math_probe`
# (`UDPSCOPE_PROBE_MATH_MS`) opens it -- "editor opened" in its log is the reading. No clicks
# are needed: the probe presents it and reopens it after a structural edit, so by the time the
# wait ends it is up. It is grabbed on its own rather than through the main window, which is
# the whole point of the figure.
echo "make-help-shots: scene 'math-editor'"
APP_ARGS="--plots 1" start_app "$tmp/math-editor.log" \
UDPSCOPE_ENABLE_ALL_MS=1500 UDPSCOPE_PROBE_MATH_MS=2500
sleep 10
grab math-editor "$tmp/math-editor.log" "$boxes_math" pick_other_window
echo "make-help-shots: done"