diff --git a/CLAUDE.md b/CLAUDE.md index cfb1ab0..acd3dbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e | 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. | -| [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. | ### 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-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-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. | diff --git a/CMakeLists.txt b/CMakeLists.txt index d034e71..90f65e1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,6 +11,8 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) endif() find_package(Threads REQUIRED) +# `CMAKE_INSTALL_DATADIR` (the `share` under the prefix) for the help set's install path. +include(GNUInstallDirs) # --- libadwaita: the shortcut overview --------------------------------------- # GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and libadwaita's @@ -61,22 +63,35 @@ add_custom_command( COMMENT "Compiling udpscope icon resources" VERBATIM) -# --- help pages: Pango markup in a GResource ---------------------------------- -# A second bundle beside the icons'. --c-name is given explicitly so the two generated -# sources cannot collide on symbol names. -set(UDPSCOPE_HELP_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/help) -file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS ${UDPSCOPE_HELP_DIR}/*.txt) -add_custom_command( - OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c - COMMAND ${GLIB_COMPILE_RESOURCES} - --target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c - --sourcedir=${UDPSCOPE_HELP_DIR} - --c-name=udpscope_help - --generate-source - ${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml - DEPENDS ${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml ${UDPSCOPE_HELP_FILES} - COMMENT "Compiling udpscope help resources" - VERBATIM) +# --- the manual: a Mallard help set, installed for the `help:` URI ------------ +# The manual is no longer a GResource compiled into the binary. It is a Mallard set that the +# desktop help viewer (Yelp on GNOME) reads off disk, because that is the only way `F1` can +# open a real, searchable, translatable document and the only way the freedesktop Help System +# Specification's `help:` URI resolves it. The user documentation is then *files*, checked and +# installed rather than compiled in. +# +# The document identifier -- the `` in `help:` -- is derived from the desktop +# file's own basename (its desktop-file id), not written out twice: the same string is the +# `.desktop` file, the GApplication id and the installed directory. The specification installs +# a document at `/help///` and resolves `help:` to the first +# match under $XDG_DATA_HOME/$XDG_DATA_DIRS, so the directory name *is* the identifier. +# NAME_WE is not used: it strips the *longest* extension, so it would cut `org.udpscope.app` +# down to `org`. Strip the two suffixes this one file actually carries, in order. +get_filename_component(_udpscope_desktop + ${CMAKE_CURRENT_SOURCE_DIR}/data/org.udpscope.app.desktop.in NAME) +string(REGEX REPLACE "\\.desktop\\.in$" "" UDPSCOPE_HELP_NAME "${_udpscope_desktop}") +unset(_udpscope_desktop) +set(UDPSCOPE_HELP_SRC ${CMAKE_CURRENT_SOURCE_DIR}/help/C/${UDPSCOPE_HELP_NAME}) +file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS + ${UDPSCOPE_HELP_SRC}/*.page ${UDPSCOPE_HELP_SRC}/figures/*.png) + +# The install rule the specification asks for. `CMAKE_INSTALL_DATADIR` is `share` by default, +# so the set lands at `/share/help/C//`, 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 ---------------------------------- # 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) # The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the # 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on -# generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon and -# help bundles are small enough per file never to need it), so the build stays at its four warnings. +# generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon bundle +# is small enough per file never to need it), so the build stays at its 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 PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings") @@ -127,6 +143,28 @@ else() message(WARNING "python3 was not found: the test-ui target is not available") endif() +# --- the manual: an opt-in check, never part of `cmake --build` ----------------- +# The manual is hand-written Mallard, and `cmake --build` never reads it -- a broken xref or a +# missing figure is a page in the viewer with no content or no image, not a build failure. +# `tools/check_help.sh` runs yelp-check's four offline subcommands over the set (links, media, +# orphans, ids) and renders the whole set to HTML with yelp-build, so the manual is checked the +# way the `.ui` files are. `yelp-check validate` is *not* used: it fetches the Mallard RelaxNG +# schema over the network, which is not available here and would make the check depend on it. +# +# No ALL: this runs only when it is named, so `cmake --build build` is unaffected and +# yelp-tools is not a build dependency. +find_program(UDPSCOPE_YELP_CHECK NAMES yelp-check) +find_program(UDPSCOPE_YELP_BUILD NAMES yelp-build) +if(UDPSCOPE_YELP_CHECK AND UDPSCOPE_YELP_BUILD) + add_custom_target(test-help + COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_help.sh ${UDPSCOPE_HELP_SRC} + DEPENDS ${UDPSCOPE_HELP_FILES} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_help.sh + COMMENT "Checking the Mallard help set with yelp-check and yelp-build" + VERBATIM) +else() + message(WARNING "yelp-check/yelp-build were not found: the test-help target is not available") +endif() + # --- build info: version, commit and timestamp compiled into the binary ------- # Written at configure time so add_executable can name the file, and again on every # build so the commit and the timestamp belong to the binary that is running rather @@ -148,7 +186,6 @@ set_source_files_properties(${UDPSCOPE_BUILD_INFO} PROPERTIES GENERATED TRUE) # --- the application ---------------------------------------------------------- add_executable(udpscope ${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c - ${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c ${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c ${UDPSCOPE_BUILD_INFO} src/main.cpp @@ -187,6 +224,12 @@ add_executable(udpscope ) target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src) +# The manual's identifier (`show_help()` builds `help:` from it) and its directory in the +# source tree (what `help_self_check()` scans). Both come from the block near the top, so the +# identifier is the desktop file's basename and nothing is written out twice. +target_compile_definitions(udpscope PRIVATE + UDPSCOPE_HELP_NAME="${UDPSCOPE_HELP_NAME}" + UDPSCOPE_HELP_DIR="${UDPSCOPE_HELP_SRC}") target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads PkgConfig::ADWAITA) add_dependencies(udpscope udpscope_build_info) diff --git a/README.md b/README.md index 5255b2e..43ce14b 100644 --- a/README.md +++ b/README.md @@ -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 memory. It used to ask two of them directly, which is where the long-standing "an exit-dump run emits exactly two `Gtk-CRITICAL`s" baseline came from — and a critical was only the lucky reading. -`UDPSCOPE_PROBE_HELP_MS` builds and destroys three windows of its own before the dump, so by then +`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 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. @@ -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. 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 -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) 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 detached from the body" looks like. -The **help window** (`F1`) carries the same screenshots as `docs/`: the overview page shows the -main window and a held capture above its text, and the trigger and math pages carry theirs. A -page's images are `GtkPicture`s **with a height request** — a shrinkable one asks for no height at -all inside a box, so without it the page renders its text with a silent gap where the screenshots -should be. The side column and the two panel toggles stay hidden until a +The **manual** (`F1`, the *Documentation* menu row) opens in the desktop help viewer — Yelp, +through the freedesktop `help:` URI — rather than in a window of udpscope's own: it is a Mallard +set installed under `/help/C/org.udpscope.app/`, not a scroll view compiled into the +binary. Its overview page shows the main window and a held capture, and the trigger and math pages +carry their own figures, all produced by `tools/make-help-shots.sh` and embedded as `
`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 — and the first CONFIG reveals the signal panel (the trigger panel stays behind its own toggle). @@ -2332,7 +2334,7 @@ reaches every widget). The full key table: | `s` | show / hide the side panel | | `f` | fit to data (the plot's own key, not an application accelerator) | | `Ctrl+E` | export visible range | -| `F1` | open the help window | +| `F1` | open the manual (`help:org.udpscope.app`) in the desktop help viewer | | `Ctrl+/` | open the shortcut overview | | `Ctrl+Q` | quit | | `+` / `-` | 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, which `AdwShortcutsItem` renders as free text. -**The 14-page help window** (overflow menu or F1) is a scrollable text view that opens on the -Overview page, so it never comes up blank: the help pages -are compiled into the binary as a GResource (`src/help/udpscope_help.gresource.xml`, -`--c-name=udpscope_help`) for the same reason the icons are — udpscope is run straight out of the -build tree with no install step, so a search path would have to be found at runtime and a missing -file fails silently. The pages are Pango markup (`.txt` bodies), validated at load; a page that -fails validation renders a one-line stand-in so the window always opens rather than crashing. -`UDPSCOPE_PROBE_HELP_MS` verifies that `parsed == pages` and that the `closed ok` line carries -no `Gtk-CRITICAL`. +**The manual** (the *Documentation* overflow-menu row, or `F1`) is a Mallard help set opened in +Yelp through the `help:` URI, *not* a scroll view compiled into the binary. `help/C/org.udpscope.app/` +holds `index.page`, one page per subject, and the annotated screenshots the pages embed; the document +identifier `org.udpscope.app` is the desktop file's own basename, so it is the `.desktop` name, the +GApplication id and the install directory at once. The set is installed by an `install()` rule to +`share/help/C/org.udpscope.app/` — the path the freedesktop Help System Specification's `help:` URI +resolves — and `show_help()` hands `help:org.udpscope.app` to whatever the desktop registered for the +scheme (Yelp on GNOME), reporting a launch failure with an alert rather than failing silently. +`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 display state (one word from a fixed set — `connecting`, `live`, `paused`, `armed`, `capturing`, diff --git a/docs/claude/build-and-run.md b/docs/claude/build-and-run.md index 1efa16a..a825ae3 100644 --- a/docs/claude/build-and-run.md +++ b/docs/claude/build-and-run.md @@ -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 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//` (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//`, installed by an `install()` rule to +`/help/C//` — where the freedesktop Help System Specification's `help:` URI resolves +`help:` — 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 buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards), diff --git a/docs/claude/invariants-gtk-and-chrome.md b/docs/claude/invariants-gtk-and-chrome.md index 2385afc..8c7862b 100644 --- a/docs/claude/invariants-gtk-and-chrome.md +++ b/docs/claude/invariants-gtk-and-chrome.md @@ -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. @@ -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. `update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in the dump is the assertion. -- **The About dialog, the help window and the shortcut overview are opened by `show_about()`, - `show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is - exactly **one `GtkWindow`** among the three — the paged help window. It closes with +- **The About dialog and the shortcut overview are opened by `show_about()` and `show_shortcuts()`, + and each closes with the matching call; the manual is *not* one of these.** `show_help()` hands + the `help:` 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 pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the math dialog fell into exactly this trap, and the pointer then points at a dead widget until the - next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`) - and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**: - they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request` - on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the - pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for - two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is - the shape that prevents the double-open defect across all three. + next attempt to open the same window. The About dialog (an `AdwAboutDialog`) is an **`AdwDialog` + and not a `GtkWindow`**: it closes with `adw_dialog_close()` and clears on `::closed`, there being + no `::close-request` on an `AdwDialog`. It shares one `on_dialog_closed()` handler with the About + pointer, keyed by a **pointer to the pointer** it must null (`&g_about`), which keeps the handler + honest without knowing which dialog it is. One `GtkWidget*` (or `AdwDialog*`) per window, nulled + in its own close handler, is the shape that prevents the double-open defect. `close_help_windows()` + (misnamed now) closes just the two dialogs; a failed *launch* is reported by the launcher's own + callback with a `GtkAlertDialog`, so a click that cannot open the manual says so. - **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at configure time (so `add_executable` can name the file) and again on every build through `udpscope_build_info` (so the commit and the timestamp belong to the binary that is running). @@ -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 case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y` tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`. -- **A help page is one `.txt`, one `` line in the GResource manifest, and one `kPages` - row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its - generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at - load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from - `parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`. - A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources). - They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height - at all inside a box, so without the request it is allocated nothing and the page renders its text - with a silent gap where its screenshots should be — which is what the first version did. +- **A help page is a Mallard file, and the manual is *not* in the binary.** `help/C//` holds + `index.page`, one `.page` per subject, and the `figures/*.png` the pages link, and the + whole set is installed (the `install()` rule in CMakeLists) to `/help/C//` — the + path the freedesktop Help System Specification's `help:` URI resolves `help:` to. The + document identifier is *derived from the desktop file's basename* (`org.udpscope.app.desktop.in` + → `org.udpscope.app`), so it is the `.desktop` name, the GApplication id and the install + directory at once; `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that one + place. `show_help()` opens `help:` with `GtkUriLauncher` (Yelp runs in its own process, so + 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.** 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 diff --git a/docs/claude/probe-readings.md b/docs/claude/probe-readings.md index 83b8103..154600d 100644 --- a/docs/claude/probe-readings.md +++ b/docs/claude/probe-readings.md @@ -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 per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read. -- `[help] pages=14 parsed=14 shortest='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` 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 @@ -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 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 -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, 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, diff --git a/docs/claude/verification.md b/docs/claude/verification.md index f0aa18b..3dcabdd 100644 --- a/docs/claude/verification.md +++ b/docs/claude/verification.md @@ -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 ``` -Two baselines every run is read against. A clean build emits **four** warnings and no more: two -`-Wmissing-field-initializers` on `MathNode::source` and `-Woverlength-strings` on **both** -generated GResource `.c` files — the help pages' (**516044** bytes) and the icons', whose blob is -**12352** against C99's 4095-byte string-literal limit with the seven action icons and the two app icons -that remain (a note about a generated file, which GCC itself supports) -- re-measured when -the tab work changed the set (that icon left, the two tab glyphs arrived, and this file's -help page was rewritten), which did not move the count; re-measure by touching each `*.gresource.xml` -under `src/` and rebuilding. The fifth warning that used to appear here was an unused +Two baselines every run is read against. A clean build emits **three** warnings and no more: two +`-Wmissing-field-initializers` on `MathNode::source` and one `-Woverlength-strings` on the icons' +generated GResource `.c`, whose blob is **12352** against C99's 4095-byte string-literal limit with +the seven action icons and the two app icons that remain (a note about a generated file, which GCC +itself supports). The *fourth* was the help pages' generated `.c` (**516044** bytes); the manual is +now a Mallard set installed for Yelp rather than a GResource compiled into the binary, so that +bundle — and its warning — is gone, and the baseline moved from four to **three**. Re-measure by +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_after_add` already printed on the same line — and that deletion is what dropped the baseline -from five to four. Nothing started reading it. And an exit-dump run emits **no** `Gtk-CRITICAL` at -all. A fifth warning, or any critical, is a finding. +`output_after_add` already printed on the same line — which dropped the baseline from five to +four.) And an exit-dump run emits **no** `Gtk-CRITICAL` at all. -An *incremental* build reads **two** of those four: the generated GResource `.c` files are not -recompiled when only `src/` changed, so their `-Woverlength-strings` do not reappear. +An *incremental* build reads **one** of those three: the generated GResource `.c` is not recompiled +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 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 runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only the lucky reading of it. A run that had built and destroyed other windows first — -`UDPSCOPE_PROBE_HELP_MS`, which opens three — has had that memory reused, and the same two calls +`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 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 diff --git a/help/C/org.udpscope.app/cursors.page b/help/C/org.udpscope.app/cursors.page new file mode 100644 index 0000000..6c9eda2 --- /dev/null +++ b/help/C/org.udpscope.app/cursors.page @@ -0,0 +1,87 @@ + + + + + + The shared vertical cursor pair and the per-row horizontal rulers, their four modes, + and how a reading is formatted. + + Martino Ferrari + + + + Cursors + +

Cursors read values off the traces. There are two kinds: one shared vertical + pair that every row carries at the same time, and horizontal rulers, one pair + per row, that belong to that row alone.

+ +
+ How to reach it + + +

Press the cursors button in the toolbar, or c.

+
+ +

The pane below the plot shows the readings and owns a Reset button. + Drag a marker to move it.

+
+ +

The mode dropdown offers four modes: vertical (the shared time pair), + horizontal (the per-row rulers), both, and mix.

+
+
+
+ +
+ Worth knowing + +

The vertical pair is one time for the whole figure, so moving it on + any row moves it on all of them. That is what makes dt meaningful across + rows.

+

A ruler belongs to its row and nothing shares it, so two rows' rulers are + independent.

+

mix is the reading mode: one line per bound 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 nearest stored sample's, 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 metric prefix so the digits sit in + 1..999: 10.52 mV, not 0.01 V. Two decimals is all + an instrument landing on an f32 sample can claim, so without the prefix a + small value would read 0.00 and the reading would be gone. A stream that + declares no unit gets the prefix anyway, written tight against the number + (585.24m) — the space separates the number from its unit, and a spaced + m with nothing after it would read as metres.

+

A marker past the newest sample reads --. With only one marker + over data the row reads partial: a difference between a number and nothing + is not a number.

+

In mix 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 on + that sample, 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. + horizontal has no time row, by design.

+

A cursor stays where you put it while a live view rolls under it, + which is what a bench scope does: its T0 is an age, so a marker left at + -1.5s 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.

+
+
+ +
+ Example +

Three traces in mix mode with both markers over data: 3 lines and 6 crossing + marks, each line reading like sine 0.31 V -0.84 V -1.15 V. Move the second + marker past the newest sample and the third column reads --.

+
+ +
diff --git a/help/C/org.udpscope.app/export.page b/help/C/org.udpscope.app/export.page new file mode 100644 index 0000000..fa8bc0b --- /dev/null +++ b/help/C/org.udpscope.app/export.page @@ -0,0 +1,78 @@ + + + + + + Writing the visible range of every plotted trace to NumPy, CSV or MDF 3.30, and what + each format carries. + + Martino Ferrari + + + + Saving and export + +

Export writes the visible range of every plotted trace to a file: NumPy + .npy, .csv, or MDF 3.30 (.mf3 or + .mdf), chosen by the extension you type.

+ +
+ How to reach it + + +

Pick Export visible range… in the overflow menu, or press + CtrlE.

+
+ +

A file chooser asks where; the format follows the path's extension.

+
+
+
+ +
+ Worth knowing + +

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 stored samples, 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 that — + read the status bar's ⓘ popover for the figure before trusting a sample count.

+

Two time columns. time 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. abs_seconds 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 mid-capture 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.

+

MDF 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 + master is abs_seconds, named time — the clock that is + the same in every display mode — and the display axis goes out beside it as an ordinary + channel, time_relative. Both are seconds; each signal carries the source's + own unit. That is also what ScryLab reads: channel name → signal name, master + → x axis, unit → the signal's unit.

+
+
+ +
+ Example +

A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns + (time, abs_seconds, three signals). The .npy is + 64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols) bytes. Read it back with + pd.DataFrame(np.load(path)). The .mf3 is bigger — a block per + channel plus a comment — 272 + comment + rows·record + 28 + channels·274 + 30 + bytes, with record = 16 + 4·cols and channels = cols + 2 for the + master and the relative axis. Read that one back with asammdf.MDF(path) or in + ScryLab.

+
+ +
diff --git a/help/C/org.udpscope.app/figures/main-window.png b/help/C/org.udpscope.app/figures/main-window.png new file mode 100644 index 0000000..d64e7d0 Binary files /dev/null and b/help/C/org.udpscope.app/figures/main-window.png differ diff --git a/help/C/org.udpscope.app/figures/math-editor.png b/help/C/org.udpscope.app/figures/math-editor.png new file mode 100644 index 0000000..d158c2c Binary files /dev/null and b/help/C/org.udpscope.app/figures/math-editor.png differ diff --git a/help/C/org.udpscope.app/figures/trigger-capture.png b/help/C/org.udpscope.app/figures/trigger-capture.png new file mode 100644 index 0000000..f46ed81 Binary files /dev/null and b/help/C/org.udpscope.app/figures/trigger-capture.png differ diff --git a/help/C/org.udpscope.app/index.page b/help/C/org.udpscope.app/index.page new file mode 100644 index 0000000..7bd5bfd --- /dev/null +++ b/help/C/org.udpscope.app/index.page @@ -0,0 +1,67 @@ + + + + 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. + + + udpscope + +
+ Getting started + +

Overview — what udpscope is, how it reaches a + source, the window and its panels, and a first five minutes.

+

Choosing a source — the command line, the + chooser, and opening an exported waveform instead of a live stream.

+

Signals and calibration — how a signal becomes + a channel, what starts acquisition, and how a scale and offset are applied.

+

Rows, layout and drag & drop — up to eight + rows, four traces each, and the layout editor.

+
+
+ +
+ Viewing + +

Time window — the one control that changes the + window, and the decimation a long window causes.

+

Navigating the plot — panning, zooming and + fitting with the mouse and the keyboard.

+

Pause and play — freezing the display without + stopping the stream, and how long you have been looking.

+

Trigger — the four trigger shapes, arming, + hold-off, the event cache, and searching the buffer.

+
+
+ +
+ Analysis + +

Cursors — the shared vertical pair and the + per-row horizontal rulers.

+

Measurements — a statistic of the visible + window, its on-plot marks, and its statistics line.

+

Math channels — building a derived channel from a + graph of source nodes, operations and one output.

+
+
+ +
+ Output and state + +

Saving and export — the visible range to + .npy, .csv or .mf3.

+

Sessions and settings — the per-source + configuration files and what they carry.

+

Status bar and diagnostics — the glance line, the + ⓘ popover, and the activity badge.

+
+
+ +
diff --git a/help/C/org.udpscope.app/layout.page b/help/C/org.udpscope.app/layout.page new file mode 100644 index 0000000..f5183c3 --- /dev/null +++ b/help/C/org.udpscope.app/layout.page @@ -0,0 +1,71 @@ + + + + + + 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. + + Martino Ferrari + + + + Rows, layout and drag & drop + +

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.

+ +
+ How to reach it + + +

Drag a signal's chip out of the side panel (s) and drop it on a row to + add a trace.

+
+ +

Press l, or pick Layout editor from the overflow menu, to add + and remove rows: a × appears at the top left of each row and a + + 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.

+
+
+
+ +
+ Worth knowing + +

A plot's own right-click menu (the same compact rows as the app's + menu) holds the y-axis scale, the two fits, and Remove signal, which swaps + the menu to that plot's traces plus All traces on this plot — with a + Back row to return. That is how a trace comes off a row without going through + the layout editor.

+

A drop adds 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 with their contents. 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 name, 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.

+
+
+ +
+ Example +

With three traces on row 1, pressing l shows 3 buttons (one ×, + two +). Clicking the + above row 1 gives 2 rows and 5 buttons, with + the three traces now on row 2 — the layout probe reports exactly this as + rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise].

+
+ +
diff --git a/help/C/org.udpscope.app/math.page b/help/C/org.udpscope.app/math.page new file mode 100644 index 0000000..c2aecfa --- /dev/null +++ b/help/C/org.udpscope.app/math.page @@ -0,0 +1,109 @@ + + + + + + Building a derived channel from a graph of source nodes, operations and one output, + and how the graph is evaluated. + + Martino Ferrari + + + + Math channels + +

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.

+ +
+ How to reach it + + +

Press the math button in the toolbar, or g.

+
+ +

The pane's + 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, add node is a menu button whose popover picks the op, + so the node arrives as the operation you asked for.

+
+
+
+ +
+ Graph gestures +

drag 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 Delete to remove + it; drag a box to move it. Wheel zooms about the pointer. Drag the + background to pan. f fits all nodes into view; + Ctrl0 resets the view. tidy, the button + beside fit in the editor, lays the graph out in topological columns and frames + it. The orange dot marks the output node; click set as output to move it.

+
+ +
+ Worth knowing + +

The graph is evaluated as an ordered list, so a node may only read ones + above 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 is 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 stored 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 acquired 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 last 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.

+
+
+ +
+ Why an operation sometimes does nothing +

A graph's output 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. Set as output moves the dot.

+ +

Apply 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 channel 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.

+ +

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 stored 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.

+ +
+ The math channel editor: nodes as boxes on a canvas, the orange dot marking the + output, and the selected node's parameters beside it. + +

A sine source wired through a gain into a filter, with the output on the last + node.

+
+
+
+ +
+ Example +

[sine] → [gain x2] gives a channel reading y=[-2, 2]. 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.

+
+ +
diff --git a/help/C/org.udpscope.app/measurements.page b/help/C/org.udpscope.app/measurements.page new file mode 100644 index 0000000..f31b069 --- /dev/null +++ b/help/C/org.udpscope.app/measurements.page @@ -0,0 +1,97 @@ + + + + + + A measurement is a statistic of the visible window of one signal, with optional + on-plot marks and a per-measurement statistics line. + + Martino Ferrari + + + + Measurements + +

A measurement is a statistic of the visible window of one signal, recomputed as + that window moves. The pane lists them with their values; each can also draw its result on + the plot.

+ +
+ How to reach it + + +

Press the measurements button in the toolbar, or m.

+
+ +

The pane's + adds one; clicking a row opens the dialog where the signal, + the type, the threshold share and the on-plot marks are set, with Save and + Delete.

+
+
+
+ +
+ Worth knowing + +

The types are average, top, bottom, min, max, peak to peak, amplitude, pulse + duration, period and frequency. top and bottom 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 level + mark is a ruler at a value, a time mark is a marker at a position. An average + draws one level mark; a max draws a level mark and 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 stepped. The number and the ruler come from the same pass, so they + always agree.

+

An invalid pass keeps its level marks and drops its time + 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 hysteresis, 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 invalid / -- + rather than a plausible invented number. Frequency over a window holding one edge is the + usual case.

+

frequency is reported in Hz and period/pulse + duration in seconds; every other type carries the channel's own unit. A value is + written with a metric prefix so its digits sit in 1..999 — + 636.62 mV, not 0.64 V — 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 (636.62m), since two decimals of an unprefixed small + value is 0.00.

+

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.

+

Statistics adds a second line to every measurement showing + n, the mean, the standard deviation and the range. Each observation is one + recompute of the pane, so n counts windows measured, not samples — + a mean frequency over many windows is what that claims to be. Reset 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.

+
+
+ +
+ Example +

On a 1 Hz ±1 sine: average=0, top=0.636620, + bottom=-0.636620, max=1, min=-1, + peak to peak=2, amplitude=1, pulse duration=0.5 s + (duty 0.5), period=1 s, frequency=1 Hz. Over a 2 M-sample window + those two passes read about 0.1% of the samples.

+
+ +
diff --git a/help/C/org.udpscope.app/navigate.page b/help/C/org.udpscope.app/navigate.page new file mode 100644 index 0000000..3c53b90 --- /dev/null +++ b/help/C/org.udpscope.app/navigate.page @@ -0,0 +1,104 @@ + + + + + + Panning, zooming and fitting the plot with the mouse and the keyboard, and what a + pinned view refuses to do. + + Martino Ferrari + + + + Navigating the plot + +

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.

+ +
+ How to reach it + + + Wheel +

Wheel zooms both axes · Shift+Wheel zooms x only · + Ctrl+Wheel zooms y only.

+
+ + Drag +

Drag pans · Shift+Drag is a box zoom on both axes · + Ctrl+Drag on x only · Alt+Drag on values + only. The area being chosen is framed while you drag it, so you can see what the + release will apply.

+
+ + Keyboard +

+ and - zoom both axes; Ctrl with them restricts + to x, Alt to y. Arrow keys pan, f fits, + Escape cancels a gesture in progress.

+
+
+ +

The full table is in the shortcut overview (Ctrl/).

+
+ +
+ Worth knowing + +

The keyboard zoom's modifiers are not the wheel's, and that is + deliberate: on common layouts + is Shift=, + 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 pinned 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 in + 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 time window — only the header + control does. A zoom or a pan moves the view inside the window; f (and + Fit X axis) is the undo 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 data does. A live axis is a fixed + [-window .. 0] 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 one + pixel column at a time: 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 crawl 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 graded by brightness, 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.

+
+
+ +
+ Example +

Pause a live view, then press Ctrl+: x narrows + and y does not move. Press Alt+: y narrows and x + does not. Eight doublings into a held 2 s capture, the trace still reaches both margins — + the zoom probe reports gap_px and end_px within a pixel or two + at every step.

+
+ +
diff --git a/help/C/org.udpscope.app/overview.page b/help/C/org.udpscope.app/overview.page new file mode 100644 index 0000000..5b6b6df --- /dev/null +++ b/help/C/org.udpscope.app/overview.page @@ -0,0 +1,169 @@ + + + + + What udpscope plots, the four parts of the window, and one pass through the + application from connecting to saving. + + Martino Ferrari + + + + Overview + +
+ What udpscope is +

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 + --buffer-mb; 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 relative to now. While streaming it is a fixed + [-window .. 0] with the newest sample at 0, so x reads as an age: + -1.5s 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.

+ +
+ 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. + +

One figure of three rows with four signals enabled, the Measure tab open in the + side panel, and the status bar reading live.

+
+
+
+ +
+ How to reach it +

Start the app with a source + (udpscope --server 127.0.0.1 --port 8000), or start it with no + --server 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 --multicast with the group and --interface with the local + address.

+ +

The signals arrive disabled. 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.

+ + +

See Choosing a source for the chooser and the multicast + options in full.

+
+
+ +
+ The window + + + The header bar +

Left to right: the time window (2 s), 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.

+
+ + The side panel +

Two tabs. Measure holds the cursors readout and the measurements + derived from it; Signals holds the trigger and the channel list, where a + math channel is a row of that one list under a MATH heading whose + + 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.

+
+ + The plot +

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).

+
+ + The status bar +

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: event held, the + axis pinned to the event, and the trigger marker at the edge.

+
+
+ +
+ 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. + +

The trigger marker sits at x = 0 and the pre-trigger share of the window is drawn + to its left.

+
+
+
+ +
+ The first five minutes + + +

Connect. With no --server, pick a stored session or type a host + and port.

+
+ +

Enable a signal 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.

+
+ +

Drag it onto a row. 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.

+
+ +

Set the window 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.

+
+ +

Pause (space) to look at a still picture; the producer keeps + filling the buffer behind it, and resuming shows what arrived meanwhile rather than a + hole.

+
+ +

Arm the trigger (a) 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.

+
+ +

Measure it — the measurements pane computes a statistic of the visible + window, and show on plot marks it as a ruler or a pair of markers.

+
+ +

Save the data with Export visible range, which writes the + plotted signals over the window at the stored rate to .npy, + .csv or .mf3 (MDF 3.30).

+
+
+
+ +
+ Worth knowing + +

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.

+
+
+ +
diff --git a/help/C/org.udpscope.app/pause.page b/help/C/org.udpscope.app/pause.page new file mode 100644 index 0000000..e3b239f --- /dev/null +++ b/help/C/org.udpscope.app/pause.page @@ -0,0 +1,61 @@ + + + + + + Pausing freezes the display without stopping the stream, and the status bar says how + long you have been paused. + + Martino Ferrari + + + + Pause and play + +

Pause freezes the display, 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.

+ +
+ How to reach it + + +

Press the pause button in the toolbar, or Space.

+
+ +

Press it again to resume, which rebinds the live ring and puts the window back on + the newest data.

+
+
+
+ +
+ Worth knowing + +

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 snapshot's own stamps, 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.

+
+
+ +
+ Example +

Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads + [-1.40 .. 0.00] rather than [-2.00 .. 0.00] with 0.6 s of blank. + The pause probe reports ring_produced still growing across the pause and + event_size non-zero exactly while paused.

+
+ +
diff --git a/help/C/org.udpscope.app/sessions.page b/help/C/org.udpscope.app/sessions.page new file mode 100644 index 0000000..f677cac --- /dev/null +++ b/help/C/org.udpscope.app/sessions.page @@ -0,0 +1,93 @@ + + + + + + Per-source configuration files: where they live, what they persist, and how the + chooser and the Configurations dialog use them. + + Martino Ferrari + + + + Sessions and settings + +

udpscope stores named sessions — configurations — 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 is the + settings.

+ +
+ How to reach it +

Nothing to open — the file is written as you work. Its path is in the About dialog, and + the directory is $XDG_CONFIG_HOME/udpscope, or ~/.config/udpscope + when that is unset. Each source has a directory <host>_<port>/ + holding one <name>.toml per configuration and an active + file naming the one it is on.

+
+ +
+ Configurations +

Configurations… in the menu lists the running source's configurations: click + a row to open it, × to delete it, or type a name and a note and press + Save this session 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 --server) 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 buffer size (--buffer-mb), 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.

+
+ +
+ Worth knowing + +

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.

+

Signal identity is the name 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 not 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 inside 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 + × and the dialog's do exactly that.

+

A session from before configurations existed is imported once as the + configuration default, and the old file is kept beside the directory as + <host>_<port>.toml.imported.

+
+
+ +
+ Example +

Connect to 127.0.0.1:8000, drop three signals on row 1, set a 2 s window, + and save the session as bench with a note. + ~/.config/udpscope/127.0.0.1_8000/bench.toml now holds + window_s=2 comment="…" with + signals = ["sine", "ramp", "noise", ""], and the next run reports + layout loaded from …/127.0.0.1_8000/bench.toml with those signals already + acquired.

+
+ +
diff --git a/help/C/org.udpscope.app/signals.page b/help/C/org.udpscope.app/signals.page new file mode 100644 index 0000000..5ca7b67 --- /dev/null +++ b/help/C/org.udpscope.app/signals.page @@ -0,0 +1,70 @@ + + + + + + How the source's CONFIG becomes channels, what enabling a signal starts, and how a + signal's scale and offset are calibrated. + + Martino Ferrari + + + + Signals and calibration + +

The source's CONFIG message describes its signals: a name, a unit and a type. udpscope + turns each enabled signal into a channel — 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.

+ +
+ How to reach it + + +

Open the side panel (s), which lists every signal with a switch.

+
+ +

The Signal list item in the overflow menu opens the editor where the + unit and the calibration live.

+
+
+
+ +
+ Worth knowing + +

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.

+

Calibrate in a signal's row menu opens the gain and offset it is + currently drawn through, so a trace already scaled by a gain of 2 shows 2 — + pressing Apply confirms it rather than resetting it. A signal nobody + calibrated shows the identity, 1 and 0.

+

Calibration is applied at ingest, 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 timestamp, 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 signal, 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.

+

Click a coloured chip to change that signal's colour. A grey chip + names no trace, so it is not clickable. The colour is saved with the session; + Default colour in the signal's ⋯ menu gives the palette entry back.

+
+
+ +
+ Example +

A source declaring time, sine, ramp and + noise yields three plottable signals. Enable all three and the exit dump + reads 3 signals, 3 channels; time is not among them.

+
+ +
diff --git a/help/C/org.udpscope.app/source.page b/help/C/org.udpscope.app/source.page new file mode 100644 index 0000000..956bdc4 --- /dev/null +++ b/help/C/org.udpscope.app/source.page @@ -0,0 +1,75 @@ + + + + + + How udpscope is pointed at a source: the command line, the startup chooser, and + opening an exported waveform instead of a live stream. + + Martino Ferrari + + + + Choosing a source + +

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.

+ +
+ How to reach it + + +

Run with --server and the app connects straight away.

+
+ +

Run without --server and a chooser appears first: one row per + stored configuration (host:port · name, its note, and the + settings it carries), newest first, plus a New host… form that takes the + address and the configuration it belongs to — its name and note, the + buffer size, 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, Waveforms, opens an exported run instead of a + live source: the files opened before (newest first; one that has since been deleted is + marked missing) and an Open… button for a new one. The same file + can be opened straight from the command line with --open FILE. 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 .npy files are read.

+
+
+ +

The chooser runs before any buffer is allocated and before any socket is opened, so + declining it costs nothing.

+
+ +
+ Worth knowing + +

The list is the sessions that named a signal. 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 + (host_port.toml) cannot carry them.

+

--multicast still needs --server: the CONFIG message is + fetched from the server, not from the group.

+

The × beside a row deletes that session file. Nothing else is + removed.

+
+
+ +
+ Example +

udpscope with two stored sessions lists + 127.0.0.1:8000 plots=1 signals=3 and + 10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9. + Activating the first reports [sources] picked 127.0.0.1:8000 and continues + into the scope with that session's layout already loaded.

+
+ +
diff --git a/help/C/org.udpscope.app/status.page b/help/C/org.udpscope.app/status.page new file mode 100644 index 0000000..5a0ad46 --- /dev/null +++ b/help/C/org.udpscope.app/status.page @@ -0,0 +1,72 @@ + + + + + The status bar's two tiers, the activity badge, the fixed state words, and the ⓘ + detail popover. + + Martino Ferrari + + + + Status bar and diagnostics + +

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.

+ +
+ How to reach it +

The bar is always visible under the plot. Click ⓘ for the detail popover.

+
+ +
+ Worth knowing + +

The badge is one glyph in four colours: green alternating 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, grey is no stream at all or a + pipeline you stopped (the tooltip says which), and yellow is a source that + answered but has sent no configuration.

+

The state field is one word from a fixed set: connecting, + live, paused, armed, capturing, + event held, disarmed. Two states add one field each: in trigger + mode, how long ago the last event was; while paused, how long you have been paused.

+

connecting means there is no signal list 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 wall 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.

+

gaps 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.

+

reconnects 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 — connected, but no data is arriving — 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 no configuration gets a different notice, + trying to connect, 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.

+

stored versus wire in the popover is the decimation. + They differ exactly when the window is too long to store at full rate.

+
+
+ +
+ Example +

The bar reads ● live · 10 kS/s · 2 s window. Behind ⓘ: + Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3 + channels, Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire + 10 kS/s), Plot 1 3 traces · 195000 samples, + Reconnects 0.

+
+ +
diff --git a/help/C/org.udpscope.app/trigger.page b/help/C/org.udpscope.app/trigger.page new file mode 100644 index 0000000..47923e5 --- /dev/null +++ b/help/C/org.udpscope.app/trigger.page @@ -0,0 +1,155 @@ + + + + + + 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. + + Martino Ferrari + + + + Trigger + +

The trigger watches one signal for a condition and captures a window around it. The + Shape 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.

+ +
+ How to reach it + + +

The trigger button in the toolbar (t) 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.

+
+ +

Arm starts watching; a arms and disarms from the keyboard. + When the pane is locked with its [edit] button — so that it states what the + trigger is set to instead of showing the controls — Arm/Disarm 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.

+
+ +

Search the buffer is at the foot of the same panel.

+
+
+ +
+ A held capture: <gui>event held</gui> in the status bar, the axis pinned to the + event, and the trigger marker at x = 0. + +

The pre-trigger share is drawn to the left of the marker.

+
+
+
+ +
+ The four shapes + + + Edge +

A crossing of the level in the chosen direction, the classic behaviour.

+
+ + Pulse width +

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.

+
+ + Window +

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.

+
+ + Dropout +

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.

+
+ +
+ The event cache, replay and compare +

Every triggered acquisition is kept. Latches are written to the event cache + (~/.cache/udpscope/events/<source>/) as a .npy of the + event, a .txt of what it was, and a .bmp picture drawn from the + samples. The Events 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 replays it: the stored event is loaded exactly like an exported + file, without changing the layout — 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. Live 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: + Compare 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 Accumulate 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 double click replays.

+ +

Record events, in the trigger panel with the trigger's other options, is + what decides whether anything is kept. It is off by default — nothing is written + to disk until you ask — and with it off the history button goes with it.

+
+ + + +
+ Worth knowing + +

Arming enables 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 display window, 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: armed + (watching, nothing drawn yet), capturing (the edge was accepted and the post + window is filling in view, with the marker at x = 0), event held + (the frozen capture) and disarmed. 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 during 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 stored 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.

+
+
+ +
+ Example +

A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans + [-1.000 .. 1.000] with n=20001 samples at 10 kS/s — the span + times the real rate, plus one — and the marker sits at x = 0 throughout.

+
+ +
diff --git a/help/C/org.udpscope.app/window.page b/help/C/org.udpscope.app/window.page new file mode 100644 index 0000000..2192cff --- /dev/null +++ b/help/C/org.udpscope.app/window.page @@ -0,0 +1,66 @@ + + + + + + The time window control: how to set it, and why a long window is decimated rather + than refused. + + Martino Ferrari + + + + Time window + +

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 this window.

+ +
+ How to reach it + + +

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.

+
+
+
+ +
+ Worth knowing + +

A window too long to store at the wire rate is decimated, not refused. + The shared clock takes a power-of-two stride, and each group of samples is + averaged 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 mean, 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.

+

This control is the only thing that changes the window. A zoom, a pan + or a fit moves the view inside 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 stored (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.

+
+
+ +
+ Example +

1 MS/s with 200 MB of buffer. A 2 s window stores every sample + (time x1). Widen it to 600 s and the stride becomes x16: 625 S/s + stored, anti-aliased at 312.5 Hz. A 2 kHz sine seen through that window reads + y=[-0.059, 0.079] — attenuated to about 6%, not folded down to a + full-amplitude 125 Hz one.

+
+ +
diff --git a/src/help/01-overview.txt b/src/help/01-overview.txt deleted file mode 100644 index 7653141..0000000 --- a/src/help/01-overview.txt +++ /dev/null @@ -1,79 +0,0 @@ -What udpscope is - -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 --buffer-mb; -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 relative to now. While streaming it is a fixed [-window .. 0] -with the newest sample at 0, so x reads as an age: -1.5s 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. - -How to reach it - -Start the app with a source (udpscope --server 127.0.0.1 --port 8000), or start it with -no --server 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 ---multicast with the group and --interface with the local address. - -The signals arrive disabled. 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. - -The window - -• The header bar -- left to right: the time window (2 s), 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. - -• The side panel -- two tabs. Measure holds the cursors readout and the - measurements derived from it; Signals holds the trigger and the channel list, where a - math channel is a row of that one list under a MATH heading whose + 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. - -• The plot -- 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). - -• The status bar, 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: event held, - the axis pinned to the event, and the trigger marker at the edge. - -The first five minutes - -1. Connect. With no --server, pick a stored session or type a host and port. -2. Enable a signal 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. Drag it onto a row. 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. Set the window 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. Pause (space) 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. Arm the trigger (a) 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. Measure it -- the measurements pane computes a statistic of the visible window, and - show on plot marks it as a ruler or a pair of markers. -8. Save the data with Export visible range, which writes the plotted signals over the - window at the stored rate to .npy, .csv or .mf3 (MDF 3.30). - -Worth knowing - -• 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. diff --git a/src/help/02-source.txt b/src/help/02-source.txt deleted file mode 100644 index 77dbcd1..0000000 --- a/src/help/02-source.txt +++ /dev/null @@ -1,42 +0,0 @@ -Choosing a source - -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. - -How to reach it - -Run with --server and the app connects straight away. Run without ---server and a chooser appears first: one row per stored configuration -(host:port · name, its note, and the settings it carries), newest first, plus a -New host… form that takes the address and the configuration it belongs to — its name -and note, the buffer size, 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, Waveforms, opens an exported run instead of a live source: the files -opened before (newest first; one that has since been deleted is marked missing) and an -Open… button for a new one. The same file can be opened straight from the command line with ---open FILE. 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 .npy 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. - -Worth knowing - -• The list is the sessions that named a signal. 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 (host_port.toml) cannot - carry them. -• --multicast still needs --server: the CONFIG message is fetched from the - server, not from the group. -• The × beside a row deletes that session file. Nothing else is removed. - -Example - -udpscope with two stored sessions lists -127.0.0.1:8000 plots=1 signals=3 and -10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9. -Activating the first reports [sources] picked 127.0.0.1:8000 and continues into -the scope with that session's layout already loaded. diff --git a/src/help/03-signals.txt b/src/help/03-signals.txt deleted file mode 100644 index d15088f..0000000 --- a/src/help/03-signals.txt +++ /dev/null @@ -1,36 +0,0 @@ -Signals and calibration - -The source's CONFIG message describes its signals: a name, a unit and a type. udpscope turns -each enabled signal into a channel — 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. - -How to reach it - -The side panel (s) lists every signal with a switch. The Signal list item in -the overflow menu opens the editor where the unit and the calibration live. - -Worth knowing - -• 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. -• Calibrate in a signal's row menu opens the gain and offset it is currently drawn - through, so a trace already scaled by a gain of 2 shows 2 — pressing Apply confirms it - rather than resetting it. A signal nobody calibrated shows the identity, 1 and 0. -• Calibration is applied at ingest, 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 timestamp, 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 signal, 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. -• Click a coloured chip to change that signal's colour. A grey chip names no trace, so - it is not clickable. The colour is saved with the session; Default colour in the - signal's ⋯ menu gives the palette entry back. - -Example - -A source declaring time, sine, ramp and noise yields three -plottable signals. Enable all three and the exit dump reads 3 signals, 3 channels; -time is not among them. diff --git a/src/help/04-layout.txt b/src/help/04-layout.txt deleted file mode 100644 index 860a780..0000000 --- a/src/help/04-layout.txt +++ /dev/null @@ -1,39 +0,0 @@ -Rows, layout and drag & drop - -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. - -How to reach it - -Drag a signal's chip out of the side panel (s) and drop it on a row to add a trace. -Press l, or pick Layout editor from the overflow menu, to add and remove rows: -a × appears at the top left of each row and a + 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. - -Worth knowing - -• A plot's own right-click menu (the same compact rows as the app's menu) holds the - y-axis scale, the two fits, and Remove signal, which swaps the menu to that plot's traces - plus All traces on this plot — with a Back row to return. That is how a trace comes - off a row without going through the layout editor. -• A drop adds 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 with their contents. 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 name, 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. - -Example - -With three traces on row 1, pressing l shows 3 buttons (one ×, two -+). Clicking the + above row 1 gives 2 rows and 5 buttons, with the three -traces now on row 2 — the layout probe reports exactly this as -rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]. diff --git a/src/help/05-window.txt b/src/help/05-window.txt deleted file mode 100644 index 4c60de2..0000000 --- a/src/help/05-window.txt +++ /dev/null @@ -1,33 +0,0 @@ -Time window - -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 this window. - -How to reach it - -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. - -Worth knowing - -• A window too long to store at the wire rate is decimated, not refused. The shared clock - takes a power-of-two stride, and each group of samples is averaged 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 mean, 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. -• This control is the only thing that changes the window. A zoom, a pan or a fit moves the - view inside 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 stored (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. - -Example - -1 MS/s with 200 MB of buffer. A 2 s window stores every sample (time x1). Widen it to -600 s and the stride becomes x16: 625 S/s stored, anti-aliased at 312.5 Hz. A 2 kHz -sine seen through that window reads y=[-0.059, 0.079] — attenuated to about 6%, not -folded down to a full-amplitude 125 Hz one. diff --git a/src/help/06-navigate.txt b/src/help/06-navigate.txt deleted file mode 100644 index 233982b..0000000 --- a/src/help/06-navigate.txt +++ /dev/null @@ -1,59 +0,0 @@ -Navigating the plot - -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. - -How to reach it - -Wheel zooms both axes · Shift+wheel zooms x only · Ctrl+wheel zooms y only. -Drag pans · Shift+drag is a box zoom on both axes · Ctrl+drag on x only · -Alt+drag on values only. -The area being chosen is framed while you drag it, so you can see what the release will apply. -+ and - zoom both axes; Ctrl with them restricts to x, Alt to y. -Arrow keys pan, f fits, Escape cancels a gesture in progress. -The full table is in the shortcut overview (Ctrl+/). - -Worth knowing - -• The keyboard zoom's modifiers are not the wheel's, and that is deliberate: on common - layouts + is shift+=, 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 pinned 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 in 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 time window — only the header control does. A - zoom or a pan moves the view inside the window; f (and Fit X axis) is the undo 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 data does. A live axis is a fixed [-window .. 0] - 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 one pixel column at a - time: 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 crawl 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 graded by brightness, 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. - -Example - -Pause a live view, then press Ctrl++: x narrows and y does not move. Press -Alt++: y narrows and x does not. Eight doublings into a held 2 s capture, the trace -still reaches both margins — the zoom probe reports gap_px and end_px within -a pixel or two at every step. diff --git a/src/help/07-pause.txt b/src/help/07-pause.txt deleted file mode 100644 index d73ebeb..0000000 --- a/src/help/07-pause.txt +++ /dev/null @@ -1,31 +0,0 @@ -Pause and play - -Pause freezes the display, 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. - -How to reach it - -The pause button in the toolbar, or Space. Press it again to resume, which rebinds the -live ring and puts the window back on the newest data. - -Worth knowing - -• 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 snapshot's own stamps, 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. - -Example - -Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads -[-1.40 .. 0.00] rather than [-2.00 .. 0.00] with 0.6 s of blank. The pause -probe reports ring_produced still growing across the pause and -event_size non-zero exactly while paused. diff --git a/src/help/08-trigger.txt b/src/help/08-trigger.txt deleted file mode 100644 index a05247f..0000000 --- a/src/help/08-trigger.txt +++ /dev/null @@ -1,80 +0,0 @@ -Trigger - -The trigger watches one signal for a condition and captures a window around it. The -Shape 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. - -How to reach it - -The trigger button in the toolbar (t) 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. Arm starts watching; a arms and disarms from the keyboard. When the pane is -locked with its [edit] button — so that it states what the trigger is set to instead of showing -the controls — Arm/Disarm 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. -Search the buffer is at the foot of the same panel. - -Worth knowing - -• Every triggered acquisition is kept. Latches are written to the event cache - (~/.cache/udpscope/events/<source>/) as a .npy of the event, a - .txt of what it was, and a .bmp picture drawn from the samples. The - Events 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 - replays it: the stored event is loaded exactly like an exported file, without changing the - layout -- 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. Live 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: - Compare 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 - Accumulate 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 double click replays. - Record events, in the trigger panel with the trigger's other options, is what decides whether - anything is kept. It is off by default -- nothing is written to disk until you ask -- and - with it off the history button goes with it. - -• Arming enables 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 display window, 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: armed (watching, - nothing drawn yet), capturing (the edge was accepted and the post window is filling - in view, with the marker at x = 0), event held (the frozen capture) and - disarmed. 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 during 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 stored 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: edge — a crossing of the level in the chosen direction, the classic - behaviour. Pulse width — 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. Window — 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. - Dropout — - 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. -• Search the buffer 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. - -Example - -A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans -[-1.000 .. 1.000] with n=20001 samples at 10 kS/s — the span times the real -rate, plus one — and the marker sits at x = 0 throughout. diff --git a/src/help/09-cursors.txt b/src/help/09-cursors.txt deleted file mode 100644 index 10bb69f..0000000 --- a/src/help/09-cursors.txt +++ /dev/null @@ -1,50 +0,0 @@ -Cursors - -Cursors read values off the traces. There are two kinds: one shared vertical pair that -every row carries at the same time, and horizontal rulers, one pair per row, that belong -to that row alone. - -How to reach it - -The cursors button in the toolbar, or c. The pane below the plot shows the readings and -owns a Reset button. Drag a marker to move it. The mode dropdown offers four modes: -vertical (the shared time pair), horizontal (the per-row rulers), -both, and mix. - -Worth knowing - -• The vertical pair is one time for the whole figure, so moving it on any row moves it on - all of them. That is what makes dt meaningful across rows. -• A ruler belongs to its row and nothing shares it, so two rows' rulers are independent. -• mix is the reading mode: one line per bound 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 nearest stored sample's, 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 metric prefix so the digits sit in 1..999: - 10.52 mV, not 0.01 V. Two decimals is all an instrument landing on an - f32 sample can claim, so without the prefix a small value would read 0.00 - and the reading would be gone. A stream that declares no unit gets the prefix anyway, written - tight against the number (585.24m) — the space separates the number from its unit, - and a spaced m with nothing after it would read as metres. -• A marker past the newest sample reads --. With only one marker over data the row - reads partial: a difference between a number and nothing is not a number. -• In mix 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 on that sample, 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. horizontal has no - time row, by design. -• A cursor stays where you put it while a live view rolls under it, which is what a bench - scope does: its T0 is an age, so a marker left at -1.5s 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. - -Example - -Three traces in mix mode with both markers over data: 3 lines and 6 crossing marks, -each line reading like sine 0.31 V -0.84 V -1.15 V. Move the second marker past the -newest sample and the third column reads --. diff --git a/src/help/10-measurements.txt b/src/help/10-measurements.txt deleted file mode 100644 index 71ac67e..0000000 --- a/src/help/10-measurements.txt +++ /dev/null @@ -1,60 +0,0 @@ -Measurements - -A measurement is a statistic of the visible window of one signal, recomputed as that -window moves. The pane lists them with their values; each can also draw its result on the plot. - -How to reach it - -The measurements button in the toolbar, or m. The pane's + adds one; clicking -a row opens the dialog where the signal, the type, the threshold share and the on-plot marks are -set, with Save and Delete. - -Worth knowing - -• The types are average, top, bottom, min, max, peak to peak, amplitude, pulse duration, period - and frequency. top and bottom 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 level mark is a ruler - at a value, a time mark is a marker at a position. An average draws one level mark; a - max draws a level mark and 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 - stepped. The number and the ruler come from the same pass, so they always agree. -• An invalid pass keeps its level marks and drops its time 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 hysteresis, 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 invalid / -- rather than a plausible - invented number. Frequency over a window holding one edge is the usual case. -• frequency is reported in Hz and period/pulse duration in seconds; - every other type carries the channel's own unit. A value is written with a metric prefix - so its digits sit in 1..999 — 636.62 mV, not 0.64 V — 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 (636.62m), since two decimals of an unprefixed - small value is 0.00. -• 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. -• Statistics adds a second line to every measurement showing n, the mean, the - standard deviation and the range. Each observation is one recompute of the pane, so - n counts windows measured, not samples — a mean frequency over many windows is what - that claims to be. Reset 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. - -Example - -On a 1 Hz ±1 sine: average=0, top=0.636620, bottom=-0.636620, -max=1, min=-1, peak to peak=2, amplitude=1, -pulse duration=0.5 s (duty 0.5), period=1 s, frequency=1 Hz. Over a -2 M-sample window those two passes read about 0.1% of the samples. diff --git a/src/help/11-math.txt b/src/help/11-math.txt deleted file mode 100644 index 31a7027..0000000 --- a/src/help/11-math.txt +++ /dev/null @@ -1,61 +0,0 @@ -Math channels - -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. - -How to reach it - -The math button in the toolbar, or g. The pane's + 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, add node is a menu button whose popover picks the op, so the -node arrives as the operation you asked for. - -Graph gestures: drag 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 Delete to remove -it; drag a box to move it. Wheel zooms about the pointer. Drag the background to -pan. f fits all nodes into view; Ctrl+0 resets the view. tidy, the button -beside fit in the editor, lays the graph out in topological columns and frames it. The orange -dot marks the output node; click set as output to move it. - -Worth knowing - -• The graph is evaluated as an ordered list, so a node may only read ones above 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 is 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 stored 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 acquired 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 last 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. - -Example - -[sine] → [gain x2] gives a channel reading y=[-2, 2]. 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. - -Why an operation sometimes does nothing. A graph's output 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. Set as output moves the dot. - -Apply 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 channel 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. - -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 stored 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. diff --git a/src/help/12-export.txt b/src/help/12-export.txt deleted file mode 100644 index 92e87d1..0000000 --- a/src/help/12-export.txt +++ /dev/null @@ -1,44 +0,0 @@ -Saving and export - -Export writes the visible range of every plotted trace to a file: NumPy .npy, -.csv, or MDF 3.30 (.mf3 or .mdf), chosen by the extension you -type. - -How to reach it - -Export visible range… in the overflow menu, or Ctrl+E. A file chooser asks where; -the format follows the path's extension. - -Worth knowing - -• 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 stored samples, 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 that — read the status bar's ⓘ popover - for the figure before trusting a sample count. -• Two time columns. time 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. abs_seconds 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 mid-capture 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. -• MDF 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 master is abs_seconds, - named time — the clock that is the same in every display mode — and the display axis - goes out beside it as an ordinary channel, time_relative. Both are seconds; each signal - carries the source's own unit. That is also what ScryLab reads: channel name → signal - name, master → x axis, unit → the signal's unit. - -Example - -A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns -(time, abs_seconds, three signals). The .npy is -64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols) bytes. Read it back with -pd.DataFrame(np.load(path)). The .mf3 is bigger — a block per channel plus a -comment — 272 + comment + rows·record + 28 + channels·274 + 30 bytes, with -record = 16 + 4·cols and channels = cols + 2 for the master and the relative -axis. Read that one back with asammdf.MDF(path) or in ScryLab. diff --git a/src/help/13-sessions.txt b/src/help/13-sessions.txt deleted file mode 100644 index 3209abc..0000000 --- a/src/help/13-sessions.txt +++ /dev/null @@ -1,60 +0,0 @@ -Sessions and settings - -udpscope stores named sessions — configurations — 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 is the settings. - -How to reach it - -Nothing to open — the file is written as you work. Its path is in the About dialog, and the -directory is $XDG_CONFIG_HOME/udpscope, or ~/.config/udpscope when that is -unset. Each source has a directory <host>_<port>/ holding one -<name>.toml per configuration and an active file naming the one it is on. - -Configurations - -Configurations… in the menu lists the running source's configurations: click a row to open -it, × to delete it, or type a name and a note and press Save this session 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 --server) 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 buffer size (--buffer-mb), 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. - -Worth knowing - -• 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 name 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 not 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 inside 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 × and the - dialog's do exactly that. -• A session from before configurations existed is imported once as the configuration - default, and the old file is kept beside the directory as - <host>_<port>.toml.imported. - -Example - -Connect to 127.0.0.1:8000, drop three signals on row 1, set a 2 s window, and save the -session as bench with a note. ~/.config/udpscope/127.0.0.1_8000/bench.toml now -holds window_s=2 comment="…" with signals = ["sine", "ramp", "noise", ""], and -the next run reports layout loaded from …/127.0.0.1_8000/bench.toml with those signals -already acquired. diff --git a/src/help/14-status.txt b/src/help/14-status.txt deleted file mode 100644 index 2d13996..0000000 --- a/src/help/14-status.txt +++ /dev/null @@ -1,46 +0,0 @@ -Status bar and diagnostics - -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. - -How to reach it - -The bar is always visible under the plot. Click ⓘ for the detail popover. - -Worth knowing - -• The badge is one glyph in four colours: green alternating 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, grey is no stream at all or a pipeline you stopped (the - tooltip says which), and yellow is a source that answered but has sent no configuration. -• The state field is one word from a fixed set: connecting, live, - paused, armed, capturing, event held, disarmed. Two - states add one field each: in trigger mode, how long ago the last event was; while paused, how - long you have been paused. -• connecting means there is no signal list 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 wall 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. -• gaps 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. -• reconnects 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 — connected, but no data is arriving — 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 no configuration gets a different notice, trying to connect, 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. -• stored versus wire in the popover is the decimation. They differ exactly - when the window is too long to store at full rate. - -Example - -The bar reads ● live · 10 kS/s · 2 s window. Behind ⓘ: -Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3 channels, -Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire 10 kS/s), -Plot 1 3 traces · 195000 samples, Reconnects 0. diff --git a/src/help/udpscope-help.gresource.xml b/src/help/udpscope-help.gresource.xml deleted file mode 100644 index f3acfa3..0000000 --- a/src/help/udpscope-help.gresource.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - 01-overview.txt - 02-source.txt - 03-signals.txt - 04-layout.txt - 05-window.txt - 06-navigate.txt - 07-pause.txt - 08-trigger.txt - 09-cursors.txt - 10-measurements.txt - 11-math.txt - 12-export.txt - 13-sessions.txt - 14-status.txt - - - ../../docs/img/main-window.png - ../../docs/img/trigger-capture.png - ../../docs/img/math-editor.png - - diff --git a/src/help_window.cpp b/src/help_window.cpp index 8ac2444..1baeca8 100644 --- a/src/help_window.cpp +++ b/src/help_window.cpp @@ -3,48 +3,34 @@ #include -#include #include #include "build_info.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:` to +// `/help///` 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 { -/// Nulled by each window's own close handler, so a second open cannot present a dead widget. -/// One pointer per window, file-scope because there is one application. -/// -/// The help window and the shortcut overview are `GtkWindow`s. The About dialog is an -/// `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; +/// Nulled by each dialog's own close handler, so a second open cannot present a dead widget. +/// 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 +/// process with no window of ours has nothing to null. AdwDialog* g_about = nullptr; -/// The overview is the one window here built from a `.ui` file, so its builder is held too: the window -/// belongs to the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the +/// The overview is built from a `.ui` file, so its builder is held too: the window belongs to +/// the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the /// builder is replaced on the next opening rather than reused. GtkWidget* g_shortcuts = 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) { (void)window; *static_cast(slot) = nullptr; @@ -58,100 +44,27 @@ void on_dialog_closed(AdwDialog* dialog, gpointer slot) { *static_cast(slot) = nullptr; } -struct HelpPage { - const char* section; ///< the group heading in the sidebar - const char* title; ///< the row's label and the page's heading - const char* resource; ///< a path under /org/udpscope/help - /// The screenshots shown above the text, as resource paths and a null terminator. A page - /// with none is the common case and the reason this is a list rather than one field: the - /// overview carries two, the window and a held capture. - const char* const* images = nullptr; -}; - -/// Adding a page is one .txt file, one 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(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; +/// The launch is asynchronous and can fail -- no help viewer installed, no handler for the +/// `help:` scheme, or a document no viewer can find -- and it runs in another process, so +/// nothing raises a window of ours. Report the failure here rather than leaving a click that +/// silently did nothing; the detail names the identifier a viewer looks for. +void on_help_launched(GObject* source, GAsyncResult* result, gpointer user) { + GtkWindow* parent = user != nullptr ? GTK_WINDOW(user) : nullptr; GError* error = nullptr; - GBytes* bytes = g_resources_lookup_data(page.resource, G_RESOURCE_LOOKUP_FLAGS_NONE, - &error); - if (bytes == nullptr) { - if (error != nullptr) g_error_free(error); - return "This page is missing from the build."; - } - gsize size = 0; - const char* data = static_cast(g_bytes_get_data(bytes, &size)); - std::string body(data, size); - g_bytes_unref(bytes); - - if (!pango_parse_markup(body.c_str(), -1, 0, nullptr, nullptr, nullptr, &error)) { - if (error != nullptr) g_error_free(error); - return "This page could not be displayed: its markup is malformed."; - } - *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); + if (!gtk_uri_launcher_launch_finish(GTK_URI_LAUNCHER(source), result, &error)) { + GtkAlertDialog* alert = gtk_alert_dialog_new("Could not open the help manual"); + const std::string detail = + std::string(error->message) + + "\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 " + "to read it."; + gtk_alert_dialog_set_detail(alert, detail.c_str()); + gtk_alert_dialog_show(alert, parent); + g_object_unref(alert); + g_error_free(error); } + // `parent` was referenced for the duration of the async call. + if (parent != nullptr) g_object_unref(parent); } } // namespace @@ -197,84 +110,46 @@ const char* about_icon_name() { HelpReport help_self_check() { HelpReport report; - report.pages = kPageCount; - for (const HelpPage& page : kPages) { - bool ok = false; - const std::string body = load_page(page, &ok); - if (ok && !body.empty()) ++report.parsed; - if (report.shortest_title.empty() || body.size() < report.shortest_bytes) { - report.shortest_title = page.title; - report.shortest_bytes = body.size(); + + // The manual is a set of files on disk now, so the reading is a file-system one: a missing + // directory leaves `topics=0 index=false`, which is a manual that is not where the build + // says it is -- and Yelp would show nothing for `help:`. + GError* error = nullptr; + GDir* dir = g_dir_open(UDPSCOPE_HELP_DIR, 0, &error); + if (dir == nullptr) { + 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; } void show_help(GtkWindow* parent) { - if (g_help != nullptr) { - gtk_window_present(GTK_WINDOW(g_help)); - return; - } - - // A builder per opening, as with every other `.ui` here: the window belongs to its object list and - // `close-request` destroys the window, so the resource is parsed again rather than a dead window - // fetched. This is where the previous one goes, and it is safe because that window has been closed - // 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), "%s", 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)); + // The freedesktop `help:` URI, handed to whatever the desktop registered for it -- Yelp on + // GNOME. The launch is asynchronous, so the parent is referenced across it and released in + // the callback; `parent` may legitimately be nullptr (the chooser runs before any window + // exists). + GtkUriLauncher* launcher = gtk_uri_launcher_new("help:" UDPSCOPE_HELP_NAME); + gtk_uri_launcher_launch(launcher, parent, nullptr, on_help_launched, + parent != nullptr ? g_object_ref(parent) : nullptr); + g_object_unref(launcher); } 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 - // 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 and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement for this widget -- - // has no icon property at all, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic - // gesture type draws the `icon` the row names, which is what puts a mouse glyph where the keycaps - // 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.) + // 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 + // and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement -- has no icon + // property, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic gesture type + // draws the `icon` the row names. (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`.) // // 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_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui"); if (g_shortcuts_ui == nullptr) return; @@ -307,7 +181,6 @@ void show_shortcuts(GtkWindow* parent) { } void close_help_windows() { - if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help)); if (g_about != nullptr) adw_dialog_close(g_about); if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts)); } diff --git a/src/help_window.hpp b/src/help_window.hpp index 3eeb064..e7e8f2a 100644 --- a/src/help_window.hpp +++ b/src/help_window.hpp @@ -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. const char* about_icon_name(); -/// The paged help window. Modal on nothing — it is a reference, so it stays usable while -/// the scope runs. Already open: presented. +/// Opens the installed Mallard manual in the desktop help viewer — Yelp, through the +/// `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); -/// What UDPSCOPE_PROBE_HELP_MS reports. `parsed` counts pages whose markup -/// pango_parse_markup() accepted and whose body is non-empty; it must equal `pages`. +/// What UDPSCOPE_PROBE_HELP_MS reports about the manual *in the tree*. `topics` is the +/// number of `.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 { - int pages = 0; - int parsed = 0; - std::string shortest_title; - std::size_t shortest_bytes = 0; + int topics = 0; + bool index = false; + int figures = 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(); /// 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. void show_shortcuts(GtkWindow* parent); -/// Closes whichever of the three windows are open. One of them is a GtkWindow -- the help -/// window, closed with gtk_window_close(), which emits close-request so its handler runs. The -/// other two are AdwDialogs, which are not GtkWindows: they close with adw_dialog_close(), -/// which emits ::closed. Either way the file-scope pointer is nulled by the handler, which is -/// what prevents a second open presenting a dead widget. Used by the probe. +/// Closes whichever of the two dialogs are open. Both are `AdwDialog`s, which are not +/// `GtkWindow`s: they close with `adw_dialog_close()`, which emits `::closed`, and the +/// file-scope pointer is nulled by the handler that prevents a second open presenting a dead +/// widget. There is no help window left to close -- the manual opens in a separate viewer +/// process. Used by the probe. void close_help_windows(); } // namespace udpscope diff --git a/src/ui.cpp b/src/ui.cpp index 26b194b..7e01bd1 100644 --- a/src/ui.cpp +++ b/src/ui.cpp @@ -5461,9 +5461,11 @@ void collect_spins(GtkWidget* w, std::vector* out) { /// 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. -/// Loads and validates every help page, opens all three windows, closes them, and reports. -/// `parsed` must equal `pages` and no page may be empty; `closed ok` with no GTK critical -/// is the crash check for three windows built and destroyed in one run. +/// Checks that the manual is present in the tree where the build says it is (`topics` and +/// `index`, both zero if it is missing), opens the About and shortcut dialogs, reads them and +/// 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, diff --git a/src/ui/udpscope-ui.gresource.xml b/src/ui/udpscope-ui.gresource.xml index a38b3f3..c707bc5 100644 --- a/src/ui/udpscope-ui.gresource.xml +++ b/src/ui/udpscope-ui.gresource.xml @@ -2,8 +2,9 @@ diff --git a/src/ui_probe.cpp b/src/ui_probe.cpp index 96d97ff..e3fa652 100644 --- a/src/ui_probe.cpp +++ b/src/ui_probe.cpp @@ -4447,7 +4447,10 @@ gboolean help_probe(gpointer user) { const std::string session = 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_shortcuts(GTK_WINDOW(a->window)); // 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. const UiShortcuts ui = ui_shortcut_rows("/org/udpscope/ui/shortcuts.ui"); 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; " "about version=%s commit=%s built=%s settings=%s icon=%s; closed ok\n", - report.pages, report.parsed, report.shortest_title.c_str(), - report.shortest_bytes, accel_count, gesture_count, accel_count + gesture_count, + report.topics, report.index ? 1 : 0, report.figures, UDPSCOPE_HELP_DIR, + accel_count, gesture_count, accel_count + gesture_count, ui.rows, ui.with_accel, ui.with_icon, ui.bare, build_info::kVersion, build_info::kCommit, build_info::kTimestamp, config_dir().c_str(), about_icon.c_str()); diff --git a/tools/check_help.sh b/tools/check_help.sh new file mode 100755 index 0000000..7a41627 --- /dev/null +++ b/tools/check_help.sh @@ -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" diff --git a/tools/make-help-shots.sh b/tools/make-help-shots.sh new file mode 100755 index 0000000..14cc0cf --- /dev/null +++ b/tools/make-help-shots.sh @@ -0,0 +1,224 @@ +#!/bin/bash +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 Martino Ferrari +# +# Regenerates the screenshots the Mallard manual carries (help/C//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//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"