Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e83ad86b8f | ||
|
|
afd2a6cdc1 | ||
|
|
41cd8e5f38 | ||
|
|
6138e6bdce | ||
|
|
fe499bfe41 | ||
|
|
d549223889 | ||
|
|
829176248a | ||
|
|
395c9caec3 | ||
|
|
669db6df9a | ||
|
|
bb62b8178c | ||
|
|
d8a79b96ba | ||
|
|
e140642ed0 | ||
|
|
6fe1b22524 | ||
|
|
1baa58b8fd | ||
|
|
c1460a6977 | ||
|
|
620473a5be | ||
|
|
908286ee89 | ||
|
|
761615c2fe | ||
|
|
62b5f14504 | ||
|
|
4e29b958a2 | ||
|
|
8b2dae7655 | ||
|
|
a43d3830f0 | ||
|
|
d8705a369f | ||
|
|
796dda4ad5 | ||
|
|
4f4851f4e3 | ||
|
|
121abbefae |
No files matched your search
@@ -38,7 +38,7 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
|
|||||||
| File | What it covers |
|
| File | What it covers |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. |
|
| [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. |
|
||||||
| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly four build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. |
|
| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly three build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. |
|
||||||
| [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. |
|
| [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. |
|
||||||
|
|
||||||
### How the application is put together
|
### How the application is put together
|
||||||
@@ -60,4 +60,4 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
|
|||||||
| [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted with hysteresis. |
|
| [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted with hysteresis. |
|
||||||
| [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. |
|
| [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. |
|
||||||
| [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. |
|
| [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. |
|
||||||
| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the six bundled icons and their authoring rules, and the help pages. |
|
| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the six bundled icons and their authoring rules, and how the Mallard manual is opened. |
|
||||||
+101
-18
@@ -11,6 +11,8 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
|||||||
endif()
|
endif()
|
||||||
|
|
||||||
find_package(Threads REQUIRED)
|
find_package(Threads REQUIRED)
|
||||||
|
# `CMAKE_INSTALL_DATADIR` (the `share` under the prefix) for the help set's install path.
|
||||||
|
include(GNUInstallDirs)
|
||||||
|
|
||||||
# --- libadwaita: the shortcut overview ---------------------------------------
|
# --- libadwaita: the shortcut overview ---------------------------------------
|
||||||
# GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and libadwaita's
|
# GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and libadwaita's
|
||||||
@@ -61,21 +63,72 @@ add_custom_command(
|
|||||||
COMMENT "Compiling udpscope icon resources"
|
COMMENT "Compiling udpscope icon resources"
|
||||||
VERBATIM)
|
VERBATIM)
|
||||||
|
|
||||||
# --- help pages: Pango markup in a GResource ----------------------------------
|
# --- the manual: a Mallard help set, installed for the `help:` URI ------------
|
||||||
# A second bundle beside the icons'. --c-name is given explicitly so the two generated
|
# The manual is no longer a GResource compiled into the binary. It is a Mallard set that the
|
||||||
# sources cannot collide on symbol names.
|
# desktop help viewer (Yelp on GNOME) reads off disk, because that is the only way `F1` can
|
||||||
set(UDPSCOPE_HELP_DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/help)
|
# open a real, searchable, translatable document and the only way the freedesktop Help System
|
||||||
file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS ${UDPSCOPE_HELP_DIR}/*.txt)
|
# Specification's `help:` URI resolves it. The user documentation is then *files*, checked and
|
||||||
add_custom_command(
|
# installed rather than compiled in.
|
||||||
OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
#
|
||||||
COMMAND ${GLIB_COMPILE_RESOURCES}
|
# The document identifier -- the `<name>` in `help:<name>` -- is derived from the desktop
|
||||||
--target=${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
# file's own basename (its desktop-file id), not written out twice: the same string is the
|
||||||
--sourcedir=${UDPSCOPE_HELP_DIR}
|
# `.desktop` file, the GApplication id and the installed directory. The specification installs
|
||||||
--c-name=udpscope_help
|
# a document at `<datadir>/help/<lang>/<document>/` and resolves `help:<document>` to the first
|
||||||
--generate-source
|
# match under $XDG_DATA_HOME/$XDG_DATA_DIRS, so the directory name *is* the identifier.
|
||||||
${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml
|
# NAME_WE is not used: it strips the *longest* extension, so it would cut `org.udpscope.app`
|
||||||
DEPENDS ${UDPSCOPE_HELP_DIR}/udpscope-help.gresource.xml ${UDPSCOPE_HELP_FILES}
|
# down to `org`. Strip the two suffixes this one file actually carries, in order.
|
||||||
COMMENT "Compiling udpscope help resources"
|
get_filename_component(_udpscope_desktop
|
||||||
|
${CMAKE_CURRENT_SOURCE_DIR}/data/org.udpscope.app.desktop.in NAME)
|
||||||
|
string(REGEX REPLACE "\\.desktop\\.in$" "" UDPSCOPE_HELP_NAME "${_udpscope_desktop}")
|
||||||
|
unset(_udpscope_desktop)
|
||||||
|
set(UDPSCOPE_HELP_SRC ${CMAKE_CURRENT_SOURCE_DIR}/help/C/${UDPSCOPE_HELP_NAME})
|
||||||
|
file(GLOB UDPSCOPE_HELP_FILES CONFIGURE_DEPENDS
|
||||||
|
${UDPSCOPE_HELP_SRC}/*.page ${UDPSCOPE_HELP_SRC}/figures/*.png)
|
||||||
|
|
||||||
|
# The install rule the specification asks for. `CMAKE_INSTALL_DATADIR` is `share` by default,
|
||||||
|
# so the set lands at `<prefix>/share/help/C/<document>/`, which is exactly where a viewer
|
||||||
|
# looks. There is no install step for the binary itself (see the desktop-entry target below),
|
||||||
|
# but the manual is *read from a datadir* by another process, so it has to be installable.
|
||||||
|
#
|
||||||
|
# `COMPONENT Help` is what lets the opt-in targets below install *only* the manual: it names this
|
||||||
|
# rule, and `cmake --install --component Help` runs it and nothing else. The ordinary
|
||||||
|
# `cmake --install` installs every component, so its behaviour is unchanged -- the component is a
|
||||||
|
# filter a caller may apply, not a change to the default.
|
||||||
|
install(DIRECTORY ${UDPSCOPE_HELP_SRC}/
|
||||||
|
DESTINATION ${CMAKE_INSTALL_DATADIR}/help/C/${UDPSCOPE_HELP_NAME}
|
||||||
|
COMPONENT Help
|
||||||
|
FILES_MATCHING PATTERN "*.page" PATTERN "*.png" PATTERN "*.jpg")
|
||||||
|
|
||||||
|
# --- installing the manual alone: prefix-wide and user-local, both opt-in -----
|
||||||
|
# A user who runs the binary out of a build tree wants *just* the manual where `help:` resolves,
|
||||||
|
# and not the `cmake --install` of a whole project. These two targets are that, one per
|
||||||
|
# destination; neither is built by default and neither is touched by `cmake --build build`.
|
||||||
|
#
|
||||||
|
# `install-help` honours `CMAKE_INSTALL_PREFIX`, so a packager or a system-wide user points it at
|
||||||
|
# `/usr` with -DCMAKE_INSTALL_PREFIX at configure time. It goes through the install machinery
|
||||||
|
# (`cmake --install`) rather than a hand-rolled copy, which is what makes DESTDIR and `--prefix`
|
||||||
|
# behave exactly as they do for the ordinary install. It cannot install system-wide on its own:
|
||||||
|
# `/usr` is root-owned, so the target writes to the configured prefix and a system-wide caller
|
||||||
|
# wraps it in `sudo` -- a build target has no more privilege than the shell that runs it.
|
||||||
|
add_custom_target(install-help
|
||||||
|
COMMAND ${CMAKE_COMMAND} --install ${CMAKE_BINARY_DIR} --component Help
|
||||||
|
COMMENT "Installing the manual into ${CMAKE_INSTALL_PREFIX}"
|
||||||
|
VERBATIM)
|
||||||
|
|
||||||
|
# `install-help-user` is the no-root case the launch-failure alert names. It resolves the user
|
||||||
|
# data directory the way `help_installed_path()` does -- `$XDG_DATA_HOME` when it is set,
|
||||||
|
# `$HOME/.local/share` otherwise, the XDG Base Directory Specification's own default -- and copies
|
||||||
|
# the set there. The copy is done by a script rather than by `cmake --install --prefix` because
|
||||||
|
# the destination is the *data directory*, not a prefix: `cmake --install`'s datadir is always
|
||||||
|
# `<prefix>/share`, so an arbitrary `$XDG_DATA_HOME` cannot be expressed as a prefix. The script
|
||||||
|
# prints every path it wrote; the copy uses the install rule's file filter, so both destinations
|
||||||
|
# carry the same tree.
|
||||||
|
add_custom_target(install-help-user
|
||||||
|
COMMAND ${CMAKE_COMMAND}
|
||||||
|
-DHELP_SRC=${UDPSCOPE_HELP_SRC}
|
||||||
|
-DHELP_NAME=${UDPSCOPE_HELP_NAME}
|
||||||
|
-P ${CMAKE_CURRENT_SOURCE_DIR}/cmake/install_help_user.cmake
|
||||||
|
COMMENT "Installing the manual into the user's XDG data directory"
|
||||||
VERBATIM)
|
VERBATIM)
|
||||||
|
|
||||||
# --- UI files: GtkBuilder XML in a GResource ----------------------------------
|
# --- UI files: GtkBuilder XML in a GResource ----------------------------------
|
||||||
@@ -97,8 +150,9 @@ add_custom_command(
|
|||||||
VERBATIM)
|
VERBATIM)
|
||||||
# The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the
|
# The generated source carries each file as one C string literal, and a `.ui` is big enough to pass the
|
||||||
# 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on
|
# 4095-character minimum the standard requires a compiler to support: `-Woverlength-strings` fires on
|
||||||
# generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon and
|
# generated *data*, not on anything anyone wrote. Silenced for this one generated file (the icon bundle
|
||||||
# help bundles are small enough per file never to need it), so the build stays at its four warnings.
|
# is small enough per file never to need it), so the build stays at its three warnings. The manual is
|
||||||
|
# no longer compiled in, so the help bundle's own warning is gone with it.
|
||||||
set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
set_source_files_properties(${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||||
PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings")
|
PROPERTIES COMPILE_OPTIONS "-Wno-overlength-strings")
|
||||||
|
|
||||||
@@ -127,6 +181,28 @@ else()
|
|||||||
message(WARNING "python3 was not found: the test-ui target is not available")
|
message(WARNING "python3 was not found: the test-ui target is not available")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# --- the manual: an opt-in check, never part of `cmake --build` -----------------
|
||||||
|
# The manual is hand-written Mallard, and `cmake --build` never reads it -- a broken xref or a
|
||||||
|
# missing figure is a page in the viewer with no content or no image, not a build failure.
|
||||||
|
# `tools/check_help.sh` runs yelp-check's four offline subcommands over the set (links, media,
|
||||||
|
# orphans, ids) and renders the whole set to HTML with yelp-build, so the manual is checked the
|
||||||
|
# way the `.ui` files are. `yelp-check validate` is *not* used: it fetches the Mallard RelaxNG
|
||||||
|
# schema over the network, which is not available here and would make the check depend on it.
|
||||||
|
#
|
||||||
|
# No ALL: this runs only when it is named, so `cmake --build build` is unaffected and
|
||||||
|
# yelp-tools is not a build dependency.
|
||||||
|
find_program(UDPSCOPE_YELP_CHECK NAMES yelp-check)
|
||||||
|
find_program(UDPSCOPE_YELP_BUILD NAMES yelp-build)
|
||||||
|
if(UDPSCOPE_YELP_CHECK AND UDPSCOPE_YELP_BUILD)
|
||||||
|
add_custom_target(test-help
|
||||||
|
COMMAND ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_help.sh ${UDPSCOPE_HELP_SRC}
|
||||||
|
DEPENDS ${UDPSCOPE_HELP_FILES} ${CMAKE_CURRENT_SOURCE_DIR}/tools/check_help.sh
|
||||||
|
COMMENT "Checking the Mallard help set with yelp-check and yelp-build"
|
||||||
|
VERBATIM)
|
||||||
|
else()
|
||||||
|
message(WARNING "yelp-check/yelp-build were not found: the test-help target is not available")
|
||||||
|
endif()
|
||||||
|
|
||||||
# --- build info: version, commit and timestamp compiled into the binary -------
|
# --- build info: version, commit and timestamp compiled into the binary -------
|
||||||
# Written at configure time so add_executable can name the file, and again on every
|
# Written at configure time so add_executable can name the file, and again on every
|
||||||
# build so the commit and the timestamp belong to the binary that is running rather
|
# build so the commit and the timestamp belong to the binary that is running rather
|
||||||
@@ -148,7 +224,6 @@ set_source_files_properties(${UDPSCOPE_BUILD_INFO} PROPERTIES GENERATED TRUE)
|
|||||||
# --- the application ----------------------------------------------------------
|
# --- the application ----------------------------------------------------------
|
||||||
add_executable(udpscope
|
add_executable(udpscope
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
${CMAKE_CURRENT_BINARY_DIR}/udpscope_resources.c
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_help_resources.c
|
|
||||||
${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
${CMAKE_CURRENT_BINARY_DIR}/udpscope_ui_resources.c
|
||||||
${UDPSCOPE_BUILD_INFO}
|
${UDPSCOPE_BUILD_INFO}
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
@@ -184,9 +259,17 @@ add_executable(udpscope
|
|||||||
src/ui_layout.cpp
|
src/ui_layout.cpp
|
||||||
src/ui_trigger.cpp
|
src/ui_trigger.cpp
|
||||||
src/ui_signals.cpp
|
src/ui_signals.cpp
|
||||||
|
src/ui_context_help.cpp
|
||||||
|
src/ui_tour.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
target_include_directories(udpscope PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
|
||||||
|
# The manual's identifier (`show_help()` builds `help:<id>` from it) and its directory in the
|
||||||
|
# source tree (what `help_self_check()` scans). Both come from the block near the top, so the
|
||||||
|
# identifier is the desktop file's basename and nothing is written out twice.
|
||||||
|
target_compile_definitions(udpscope PRIVATE
|
||||||
|
UDPSCOPE_HELP_NAME="${UDPSCOPE_HELP_NAME}"
|
||||||
|
UDPSCOPE_HELP_DIR="${UDPSCOPE_HELP_SRC}")
|
||||||
target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads
|
target_link_libraries(udpscope PRIVATE plot::gtk4 udpsclient Threads::Threads
|
||||||
PkgConfig::ADWAITA)
|
PkgConfig::ADWAITA)
|
||||||
add_dependencies(udpscope udpscope_build_info)
|
add_dependencies(udpscope udpscope_build_info)
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ Every size it prints is read from a value the frame tick captured while the wind
|
|||||||
from the widget: the dump runs from the destroy handler, where the widget pointer is already freed
|
from the widget: the dump runs from the destroy handler, where the widget pointer is already freed
|
||||||
memory. It used to ask two of them directly, which is where the long-standing "an exit-dump run
|
memory. It used to ask two of them directly, which is where the long-standing "an exit-dump run
|
||||||
emits exactly two `Gtk-CRITICAL`s" baseline came from — and a critical was only the lucky reading.
|
emits exactly two `Gtk-CRITICAL`s" baseline came from — and a critical was only the lucky reading.
|
||||||
`UDPSCOPE_PROBE_HELP_MS` builds and destroys three windows of its own before the dump, so by then
|
`UDPSCOPE_PROBE_HELP_MS` builds and destroys the About and shortcut dialogs before the dump, so by then
|
||||||
the memory had been reused and the same two calls **crashed in the middle of the dump**, which
|
the memory had been reused and the same two calls **crashed in the middle of the dump**, which
|
||||||
looks like a truncated log rather than like a segfault in a debug line. The dump now makes no
|
looks like a truncated log rather than like a segfault in a debug line. The dump now makes no
|
||||||
`gtk_widget_*` call at all and a clean run carries no critical.
|
`gtk_widget_*` call at all and a clean run carries no critical.
|
||||||
@@ -89,7 +89,7 @@ src/channel.* ChannelBuffer (a ring pair as a plot::data::Series) and SlotS
|
|||||||
src/acquisition.* the UDPStreamer client, the producer thread, frame decoding
|
src/acquisition.* the UDPStreamer client, the producer thread, frame decoding
|
||||||
src/trigger.* the trigger configuration and the producer-side state machine
|
src/trigger.* the trigger configuration and the producer-side state machine
|
||||||
src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x link group
|
src/plot_host.* the one plot::Figure, its rows' traces and cursors, the x link group
|
||||||
src/export.* the visible range as a table, and the .npy / .csv writers
|
src/export.* the visible range as a table, and the .npy / .csv / .mf3 writers
|
||||||
src/layout_store.* the per-source session layout file (TOML), written and read back
|
src/layout_store.* the per-source session layout file (TOML), written and read back
|
||||||
src/event_series.hpp an immutable captured event as a plot::data::Series
|
src/event_series.hpp an immutable captured event as a plot::data::Series
|
||||||
src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box
|
src/ui.* the GTK4 window: toolbar, trigger panel, signal panel, plot box
|
||||||
@@ -232,18 +232,47 @@ measurement.
|
|||||||
| type | what it is | options | shown on the plot as |
|
| type | what it is | options | shown on the plot as |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `average` | the mean of the visible samples | — | a ruler at the mean |
|
| `average` | the mean of the visible samples | — | a ruler at the mean |
|
||||||
| `top` / `bottom` | the high / low level: the mean of the samples above / below the split | split % | a ruler at the level |
|
| `top` / `bottom` | the high / low **flat** level: the densest band of the window on that side of the split | split % (which side) | a ruler at the level |
|
||||||
| `max` / `min` | the extremes | — | a ruler at the value **and** a marker where it happened |
|
| `max` / `min` | the extremes | — | a ruler at the value **and** a marker where it happened |
|
||||||
|
| `peak to peak` | the swing, `max - min` — the extremes, ripple and overshoot included | — | a ruler at each extreme |
|
||||||
|
| `amplitude` | the two flat levels' delta, `top - bottom` | — | a ruler at each flat level |
|
||||||
| `pulse duration` | the mean width of the pulse-side runs | split %, which side is the pulse | markers at the pulse's two edges |
|
| `pulse duration` | the mean width of the pulse-side runs | split %, which side is the pulse | markers at the pulse's two edges |
|
||||||
| `period` / `frequency` | the spacing of the threshold crossings / its reciprocal, in Hz | — | markers at two edges of a cycle |
|
| `period` / `frequency` | the spacing of the threshold crossings / its reciprocal, in Hz | — | markers at two edges of a cycle |
|
||||||
|
|
||||||
|
**`top`/`bottom`/`amplitude` read a *flat* level, and that is a different question from the
|
||||||
|
extremes.** A band-limited edge does not step, it overshoots and rings: the mock's `square` at 30
|
||||||
|
harmonics reaches **±1.179** where its plateau is ±1, so anything that reads the window's extremes
|
||||||
|
reports the ringing — `amplitude` used to read `1.179350` there and `top` `0.986496`, both of them
|
||||||
|
a property of the edges rather than of the waveform. A flat level is a *density* question instead:
|
||||||
|
the plateau holds most of the samples and the ringing a few percent. Pass one folds a 64-bin
|
||||||
|
histogram of the whole buckets' midpoints while it has them in hand, and the level is, either side
|
||||||
|
of the split, the densest bin that still holds half the densest one's buckets — **the outermost of
|
||||||
|
those**, which is what makes a waveform with no plateau at all (a sine, a ramp) read its *peak*
|
||||||
|
rather than an arbitrary mid-range bin — with the exact mean of the samples of the buckets within
|
||||||
|
**5 % of the window's peak-to-peak** of it. Five per cent is deliberately under the ~9 %-of-swing
|
||||||
|
Gibbs overshoot, so the ringing falls outside the band and a plateau's own ripple stays inside.
|
||||||
|
`amplitude` is then that pair's delta, which is a bench scope's `Vamplitude` (`Vtop - Vbase`) and
|
||||||
|
**not** the half-swing it used to be. Measured on the mock, harmonics `0` → `30`: `top`
|
||||||
|
`1.000000` → `0.999983`, `bottom` `-1.000000` → `-0.999983`, `amplitude` `2.000000` → `1.999966`,
|
||||||
|
while `peak to peak` goes `2.000000` → `2.358701`. On the probe's 1 Hz ±1 sine `top` reads
|
||||||
|
`0.957988` — it used to be `0.636620`, the half-wave mean, and a sine's flat level is near its
|
||||||
|
peak instead — with `sd 0.0014` across recomputes, and `amplitude` `1.915983`; `peak to peak` is
|
||||||
|
still exactly `2`. The histogram is the *same* fetched bucket list read a second time and never a
|
||||||
|
sample, so `measure cost:`'s `raw` does not move; a pane with none of the three types does not pay
|
||||||
|
for it at all. Where the pyramid cannot summarize the window — an `hf`-filtered walk, or one
|
||||||
|
shorter than a bucket — there is no density estimate, and the types fall back to the old
|
||||||
|
behaviour: the mean of each side of the split, and `max - min` for `amplitude`.
|
||||||
|
|
||||||
`period` and `frequency` deliberately take **no options**: the engine counts *rising*
|
`period` and `frequency` deliberately take **no options**: the engine counts *rising*
|
||||||
crossings of the *mid* level, one edge per cycle, and a split or a side to choose from could
|
crossings of the *mid* level, one edge per cycle, and a split or a side to choose from could
|
||||||
only make that count wrong — a split near either extreme leaves the hysteresis band below no room
|
only make that count wrong — a split near either extreme leaves the hysteresis band below no room
|
||||||
to reject noise with, and counting both edges halves the period.
|
to reject noise with, and counting both edges halves the period.
|
||||||
|
|
||||||
The split is a share of the **window's own** min..max, not volts: a measurement is about this
|
The split is a share of the **window's own** min..max, not volts: a measurement is about this
|
||||||
signal in this window, and a level in volts would have to be typed for every signal.
|
signal in this window, and a level in volts would have to be typed for every signal. For
|
||||||
|
`top`/`bottom` it says **which side** the level is read from, so any split between the two
|
||||||
|
plateaus reads the same number — it is no longer a mean *over* the side, which is the reading a
|
||||||
|
ringing edge used to drag.
|
||||||
|
|
||||||
Two more options ride on it, both at their old behaviour by default:
|
Two more options ride on it, both at their old behaviour by default:
|
||||||
|
|
||||||
@@ -283,9 +312,9 @@ and to white noise at σ=0.05; at 5 % of peak-to-peak it already fails at 0.2.
|
|||||||
Three things about it are deliberate, and each was wrong first.
|
Three things about it are deliberate, and each was wrong first.
|
||||||
|
|
||||||
- **The band moves the edge decision only.** The side the `sum`/`count` split uses stays the bare
|
- **The band moves the edge decision only.** The side the `sum`/`count` split uses stays the bare
|
||||||
level, because that split *is* `top` and `bottom` — the mean of the samples above and below the
|
level, and nothing that band touches is the flat level: a band there would change what a crossing
|
||||||
threshold — and a band there changes a documented exact reading. `top` on a unit sine must still
|
counts as an edge for no gain to anything, and the split's two means are now only the fallback
|
||||||
be `0.636620`, 2/π.
|
`top`/`bottom` take on a window with no bucket histogram.
|
||||||
- **The band is clamped to stay inside the data**, to half the room between the level and the
|
- **The band is clamped to stay inside the data**, to half the room between the level and the
|
||||||
nearer extreme. `pulse duration` lets the user put the split near an extreme (a 3.3 V logic
|
nearer extreme. `pulse duration` lets the user put the split near an extreme (a 3.3 V logic
|
||||||
signal with ringing wants it nearer the top), and a fixed tenth of the span there puts the upper
|
signal with ringing wants it nearer the top), and a fixed tenth of the span there puts the upper
|
||||||
@@ -353,7 +382,8 @@ Both walks go through it:
|
|||||||
and one raw scan of that bucket alone then pins it to a sample — two buckets at most, whatever
|
and one raw scan of that bucket alone then pins it to a sample — two buckets at most, whatever
|
||||||
the window's size. The two stamps the scan needed are read at the end rather than at every
|
the window's size. The two stamps the scan needed are read at the end rather than at every
|
||||||
chunk that moved an extreme, since a stamp is a ring read and an extreme moves many times.
|
chunk that moved an extreme, since a stamp is a ring read and an extreme moves many times.
|
||||||
- **Pass two** (the threshold types: `top`, `bottom`, `pulse duration`, `period`, `frequency`)
|
- **Pass two** (the crossing types — `pulse duration`, `period`, `frequency` — and, as a fallback
|
||||||
|
for a window with no histogram, `top`/`bottom`)
|
||||||
folds a bucket's sums whenever it lies *strictly* on one side of its level — then every sample
|
folds a bucket's sums whenever it lies *strictly* on one side of its level — then every sample
|
||||||
in it falls in the same bin and none is *at* the level, so the fold is exact. It then asks
|
in it falls in the same bin and none is *at* the level, so the fold is exact. It then asks
|
||||||
separately whether the bucket *enters* a side of the hysteresis band: one wholly past a threshold
|
separately whether the bucket *enters* a side of the hysteresis band: one wholly past a threshold
|
||||||
@@ -364,11 +394,14 @@ Both walks go through it:
|
|||||||
The raw path is still there and still exact, for the window's partial edge buckets, for the
|
The raw path is still there and still exact, for the window's partial edge buckets, for the
|
||||||
samples produced since this frame's `acquire_all()` (bounded by one frame of production), and
|
samples produced since this frame's `acquire_all()` (bounded by one frame of production), and
|
||||||
for those opened buckets. Nothing is decimated or strided: `peak to peak` still reads
|
for those opened buckets. Nothing is decimated or strided: `peak to peak` still reads
|
||||||
exactly `2` on a unit sine and `top` exactly `0.636620` — 2/π, the half-wave mean, which is the
|
exactly `2` on a unit sine, and the shared pass that carries it is untouched — `top` has no
|
||||||
check that a threshold type is not merely plausible. A window shorter than a bucket, or a signal
|
*exact* reading to protect any more, because it is a flat level read from a density rather than
|
||||||
|
the half-wave mean it used to be (`0.636620` on a unit sine; it is `0.957988` now, near the peak,
|
||||||
|
because a sine has no plateau). A window shorter than a bucket, or a signal
|
||||||
noisy enough to cross a threshold everywhere, degrades back towards the O(window) cost and nothing
|
noisy enough to cross a threshold everywhere, degrades back towards the O(window) cost and nothing
|
||||||
else changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly
|
else changes. The dump's `measure cost:` line reports `covered`, `buckets` and `raw` for exactly
|
||||||
this: `raw` near `covered` means the pyramid bought nothing.
|
this: `raw` near `covered` means the pyramid bought nothing — and `raw` is unchanged by the flat
|
||||||
|
levels, which read the *same* bucket list a second time and never a sample.
|
||||||
|
|
||||||
### What the math channels cost
|
### What the math channels cost
|
||||||
|
|
||||||
@@ -730,7 +763,11 @@ same session twice). Connector settings are stored even for a configuration with
|
|||||||
which is why those rows exist at all. The app's
|
which is why those rows exist at all. The app's
|
||||||
menu has **Configurations…**: the same list for the running source, with *Save this session* —
|
menu has **Configurations…**: the same list for the running source, with *Save this session* —
|
||||||
a name and a note — and a delete button per row. Opening one saves the current session first, so
|
a name and a note — and a delete button per row. Opening one saves the current session first, so
|
||||||
switching is never the gesture that loses what it switches from.
|
switching is never the gesture that loses what it switches from. Beside **Preferences…** the chooser
|
||||||
|
also carries a **help menu** with the window's three help entries — the manual, the contextual-help
|
||||||
|
toggle and the guided tour — because the chooser is shown to exactly the user who has not started yet
|
||||||
|
and does not know how to; the tour is started by the app once its window is up (see
|
||||||
|
[the help section](#the-help-submenu-the-contextual-help-and-the-guided-tour)).
|
||||||
|
|
||||||
A session written before configurations existed is imported on first use as the configuration
|
A session written before configurations existed is imported on first use as the configuration
|
||||||
named `default`: copied into the directory, remembered as active, and the old file renamed to
|
named `default`: copied into the directory, remembered as active, and the old file renamed to
|
||||||
@@ -844,9 +881,10 @@ reporting a problem had to retype. And the string it shows is a `std::string`, w
|
|||||||
buffer that no one notices until the path matters.
|
buffer that no one notices until the path matters.
|
||||||
|
|
||||||
What it cost: an `AdwAboutDialog` is an `AdwDialog` and **not** a `GtkWindow`, so its close path
|
What it cost: an `AdwAboutDialog` is an `AdwDialog` and **not** a `GtkWindow`, so its close path
|
||||||
differs from the help window sitting beside it -- `adw_dialog_close()` and `::closed`, rather than
|
differs from the shortcut overview's -- `adw_dialog_close()` and `::closed`, rather than
|
||||||
`gtk_window_close()` and `::close-request`. That is the same trap the shortcut overview already
|
`gtk_window_close()` and `::close-request`. That is the same trap the shortcut overview already
|
||||||
had, and the two now share one `::closed` handler keyed by the pointer it must null. What was
|
had; each keeps a close handler that nulls its own pointer -- the About dialog on `::closed`, the
|
||||||
|
overview on `::close-request` -- so neither can be presented again after it is gone. What was
|
||||||
deliberately left out: credits (one author, already named), release notes (there are no releases)
|
deliberately left out: credits (one author, already named), release notes (there are no releases)
|
||||||
and an issue-tracker URL (the website link reaches the repository, which carries the tracker).
|
and an issue-tracker URL (the website link reaches the repository, which carries the tracker).
|
||||||
|
|
||||||
@@ -871,6 +909,113 @@ built, which for a program under development is what you want. No `StartupWMClas
|
|||||||
GTK takes the WM class from the application ID, and the desktop file's basename is that same
|
GTK takes the WM class from the application ID, and the desktop file's basename is that same
|
||||||
string, which is what associates a running window with the entry.
|
string, which is what associates a running window with the entry.
|
||||||
|
|
||||||
|
## The Help submenu, the contextual help and the guided tour
|
||||||
|
|
||||||
|
**The hamburger menu's help trio became a `Help` submenu**, which is where every GNOME menu keeps
|
||||||
|
these four: the **Keyboard shortcuts** overview and the **Documentation** window (the two dialogs
|
||||||
|
the program already had, reached through `show_shortcuts()`/`show_help()` exactly as their keys
|
||||||
|
reach them), the **Guided tour**, and a stateful **Enable contextual help** row.
|
||||||
|
|
||||||
|
The submenu *replaces* the menu's rows rather than floating beside them -- the same shape, and the
|
||||||
|
same reason, as the plot's right-click menu: a submenu torn out of the menu it belongs to is a
|
||||||
|
second thing to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the
|
||||||
|
moment anything else closed its popover. It is a `GtkStack` whose visible page is the view, and not
|
||||||
|
a rebuild of the box: the main view holds `app.layout_toggle`, which the frame tick reads
|
||||||
|
(`sync_layout_editor()`) and the `L` accel moves, so rebuilding the rows would leave that pointer on
|
||||||
|
a destroyed widget, and *swapping the popover's child* would leave the view that is not showing
|
||||||
|
without an owner. A page swap has neither problem.
|
||||||
|
|
||||||
|
**The accelerators are `Ctrl+/` and `F1` for the two dialogs -- they already were -- plus `Ctrl+T`
|
||||||
|
for the tour and `Ctrl+H` for the contextual help.** Both new keys are rows in
|
||||||
|
`src/shortcuts.cpp` *and* in `src/ui/shortcuts.ui`, because the `[help]` probe checks the two
|
||||||
|
against each other (`ui` must equal `tables`).
|
||||||
|
|
||||||
|
**The contextual help is one table and a "?" per surface.** `src/ui_context_help.cpp` carries the
|
||||||
|
rows -- the plot, the trigger, the cursors, the measurements, the math channels, the signals, the
|
||||||
|
event history, the time window, the status line, and the math editor's identity row -- and each row
|
||||||
|
names the *heading box* its button joins, so adding a surface is a row and a widget pointer. The
|
||||||
|
button is a sibling of the heading's title, not an overlay on its corner, for the reason the
|
||||||
|
toolbar's icons are not overlaid either: an overlay child sits *on* whatever is under it, and the
|
||||||
|
one surface whose corner is free (the plot) is the one that uses one.
|
||||||
|
|
||||||
|
**Off means gone, not greyed, and off is the default.** The toggle removes the buttons rather than
|
||||||
|
disabling them, and which buttons exist is decided by a data key rather than a list of pointers, so
|
||||||
|
the math editor -- whose heading is created afresh every time it opens -- cannot leave a stale
|
||||||
|
pointer behind. The setting is `contextual_help` in `settings.toml`, written by
|
||||||
|
`Settings`/`load_settings()`/`save_settings()`: the same file, the same three functions and the same
|
||||||
|
`~/.config/udpscope/settings.toml` the Preferences dialog writes. **A fresh install has no such key
|
||||||
|
and the default is `false`**, so a first run shows the window as anyone would expect it and the layer
|
||||||
|
is asked for -- from the Help menu, from the source chooser's own help menu, or with `Ctrl+H`. The
|
||||||
|
dialog's own rows start from `load_settings()`, so a save from there carries the two help keys
|
||||||
|
through untouched; the Help menu's row is the only place in the *window* that states `contextual_help`
|
||||||
|
(which is why the row's check *is* the setting), and the chooser's row states it too, before any
|
||||||
|
window exists.
|
||||||
|
|
||||||
|
**The source chooser carries the same three affordances, because it is shown to exactly the user who
|
||||||
|
has not started yet.** Its header bar has a help `GtkMenuButton` beside *Preferences*, whose rows are
|
||||||
|
the window's own `menu_button()`/`menu_toggle()` -- the same shape, and the same `.udpscope-menu`
|
||||||
|
stylesheet, whose rules `choose_source()` installs before building them since the app installs them
|
||||||
|
later. *Documentation* opens the manual directly (it is another process and needs nothing of the app),
|
||||||
|
*Enable contextual help* writes the settings file the app reads a moment later, and *Guided tour*
|
||||||
|
starts **the chooser's own walk** -- the surfaces a first run meets first are this dialog's own --
|
||||||
|
whose last step sets **`Config::tour_pending`**, the value `main()` owns and hands to both the chooser
|
||||||
|
and the front end (`open_waveform` crosses the same way). Half a second and a half after the window is
|
||||||
|
up, the same delay the first-run auto-start uses, the pending request starts the window's tour; it sets
|
||||||
|
`tour_requested` rather than `tour_auto_started`, so the readings say which of the two it was. The
|
||||||
|
chooser's rows show **no accelerators** -- the window prints `F1`/`Ctrl+T` because the window binds
|
||||||
|
them, and this dialog binds none.
|
||||||
|
|
||||||
|
**The guided tour is five steps on a `GtkOverlay`** inserted above the window's content: the plot,
|
||||||
|
the side panel, the trigger, the signals and the status line. There is no popover anywhere in it and
|
||||||
|
therefore no pointer grab to lose: what makes the tour modal is four dim rectangles -- above, below
|
||||||
|
and either side of the target -- that swallow the clicks that land on them. A stop declares the
|
||||||
|
**page it stands on** (the trigger and the signals steps live on the sidebar's *Signals* page, named
|
||||||
|
in `TourStop::page`), and the tour selects that page as the step is entered -- on the frame *after*
|
||||||
|
the switch, since a stack allocates a new page's widgets a frame late -- so the spotlight lands on a
|
||||||
|
widget the user had left behind on the other tab instead of on nothing. `gtk_widget_get_mapped()`
|
||||||
|
decides which steps exist at all: a stop on the *wrong page* is kept, because selecting its page
|
||||||
|
reveals it, while one whose *pane is collapsed* is dropped, because the stack is unmapped with it
|
||||||
|
(a hidden page keeps its own *visible* flag true -- the widget's own flag, which is all
|
||||||
|
`is_visible()` walks -- so the old test walked over surfaces that were not on screen). The chooser's
|
||||||
|
tour works the same way over its own two pages: its tabs step shows the *Waveforms* page and
|
||||||
|
stepping off it -- in either direction -- brings the *Sources* page back.
|
||||||
|
|
||||||
|
Everything is placed from `gtk_widget_compute_bounds()`, re-read on **every frame**, because a
|
||||||
|
widget's allocation is 0 on the frame it is first shown and a window resize moves it under a
|
||||||
|
one-shot measurement. The hole is kept from the last frame that could measure the target for the
|
||||||
|
same reason: a frame can arrive between two layout passes, and a hole that emptied there would make
|
||||||
|
the spotlight and the card jump. The card goes under the target, over it, or in the column beside it
|
||||||
|
-- whichever has room -- and only takes the bottom corner when the target *is* the whole content,
|
||||||
|
which is what happens to the plot step in the documented headless run (a 640x480 Xvfb screen gives
|
||||||
|
the window its 756x894 minimum, and at that width the split view overlays the side panel instead of
|
||||||
|
putting it beside the plot). The probe prints the two rectangles (`spot=`, `card=`) rather than
|
||||||
|
asserting a placement, so that case reads as the geometry it is.
|
||||||
|
|
||||||
|
**A first run gets the tour, once.** The decision is `settings.tour_seen`, and it is a key of its
|
||||||
|
own rather than "the settings file did not exist": a user who had already changed a theme, or the
|
||||||
|
cache size, has a settings file and has still never seen the tour. It is written when the tour
|
||||||
|
**starts**, not when it finishes, because a user who skips it in the first second has still seen it
|
||||||
|
and a second launch that offered the whole tour again would be a nuisance. The auto-start is
|
||||||
|
scheduled 1.5 s after the window is built -- the window has to be up and laid out for there to be
|
||||||
|
anything to point at -- and the menu (or `Ctrl+T`) starts the same tour at any time.
|
||||||
|
|
||||||
|
Dismissing it is `Skip`, `Finish` on the last step, or `Escape`; all three hide every child of the
|
||||||
|
overlay. The probe asserts what "left nothing behind" means as a *reading*: four dim rectangles,
|
||||||
|
the outline and the card all off screen, and **the window carrying exactly the event controllers it
|
||||||
|
had before the tour opened**.
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_HELPUI_MS` drives all of it: the first-run auto-start, the menu's seven rows and the
|
||||||
|
submenu's five, the toggle and the count of "?" affordances on either side of it, and the tour
|
||||||
|
started, stepped forwards and back, and dismissed. Its readings are in
|
||||||
|
[probe-readings.md](docs/claude/probe-readings.md).
|
||||||
|
|
||||||
|
**The dump also carries the geometry the help's annotated figures crop**, `help figures:`, with each
|
||||||
|
of ten surfaces' `x,y,width,height` in **window coordinates** -- the frame a screenshot is cropped
|
||||||
|
in -- or `-1,-1,0,0` when the surface is not on screen. It is measured on the tick while the window
|
||||||
|
is alive, and it exists because a crop box drawn round a region of a screenshot has to be measured
|
||||||
|
rather than eyeballed: the panels are where `gtk_widget_compute_bounds()` says they are, and a
|
||||||
|
plausible guess is not.
|
||||||
|
|
||||||
## How the spec maps onto the code
|
## How the spec maps onto the code
|
||||||
|
|
||||||
**One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel`
|
**One buffer of `N+1` slices, plus frozen event snapshots.** `ScopeModel`
|
||||||
@@ -1111,8 +1256,11 @@ assertion, being a stream stamp rebased by whichever rebuild last dropped histor
|
|||||||
`probe-readings.md`.
|
`probe-readings.md`.
|
||||||
|
|
||||||
A shape's own rows are the only ones shown; the rest are hidden, so the panel never offers a
|
A shape's own rows are the only ones shown; the rest are hidden, so the panel never offers a
|
||||||
width to a window trigger. The condition is **not** persisted in the session file — the session
|
width to a window trigger. The condition **is** persisted in the session file, together with trigger
|
||||||
has never persisted the trigger — so a restart comes up on an edge trigger.
|
mode itself: the settings are written even while the trigger is off, because a trigger set up once and
|
||||||
|
switched off is still the trigger you come back to. The signal it names is stored by *name* — every
|
||||||
|
rebuild renumbers the channels — and the shapes by the words the panel's dropdowns show, so a
|
||||||
|
reordered enumeration cannot reinterpret a file.
|
||||||
|
|
||||||
### Searching the buffer
|
### Searching the buffer
|
||||||
|
|
||||||
@@ -1413,11 +1561,15 @@ a relative tolerance (clamping to *exactly* `window_s_` can leave the group's sp
|
|||||||
and a clamp that fires every frame is a re-frame loop — the dump counts it as `clamp fires:`), and
|
and a clamp that fires every frame is a re-frame loop — the dump counts it as `clamp fires:`), and
|
||||||
the sample-window walk it does is unchanged.
|
the sample-window walk it does is unchanged.
|
||||||
|
|
||||||
**Amplitude and peak to peak** are the two new types: `max - min`, and half of it. Deliberately
|
**Amplitude and peak to peak** were the two types added then: `max - min`, and half of it.
|
||||||
`(max - min) / 2` rather than `max(|value|)` — the latter is a distance from zero, which for a
|
Deliberately `(max - min) / 2` rather than `max(|value|)` — the latter is a distance from zero,
|
||||||
signal riding on an offset is a different and less useful number. Both come out of the shared
|
which for a signal riding on an offset is a different and less useful number. Both came out of the
|
||||||
pass, so they are free, and both read exactly what a unit sine should: `amplitude=1`,
|
shared pass, so they were free, and both read exactly what a unit sine should: `amplitude=1`,
|
||||||
`peak to peak=2`.
|
`peak to peak=2`. **`amplitude` no longer means that.** It is `top - bottom`, the delta between the
|
||||||
|
two *flat* levels — a bench scope's `Vamplitude` — and it is the extremes that stay with
|
||||||
|
`peak to peak`, which is why it reads `2.000000` on the rippled square where the swing is
|
||||||
|
`2.358701`: a ringing edge's overshoot belongs to the swing and not to the amplitude (see
|
||||||
|
*Measurements*).
|
||||||
|
|
||||||
The second is subtler and needs a saturated ring. A `ChannelBuffer` sample is a (value,
|
The second is subtler and needs a saturated ring. A `ChannelBuffer` sample is a (value,
|
||||||
stamp) pair split across two rings of equal capacity, and the published window is pinned
|
stamp) pair split across two rings of equal capacity, and the published window is pinned
|
||||||
@@ -1654,11 +1806,12 @@ status bar opens its detail **above** the button: below is off the window's bott
|
|||||||
bubble whose body is clipped away while its top edge stays at the button is what "the arrow is
|
bubble whose body is clipped away while its top edge stays at the button is what "the arrow is
|
||||||
detached from the body" looks like.
|
detached from the body" looks like.
|
||||||
|
|
||||||
The **help window** (`F1`) carries the same screenshots as `docs/`: the overview page shows the
|
The **manual** (`F1`, the *Documentation* menu row) opens in the desktop help viewer — Yelp,
|
||||||
main window and a held capture above its text, and the trigger and math pages carry theirs. A
|
through the freedesktop `help:` URI — rather than in a window of udpscope's own: it is a Mallard
|
||||||
page's images are `GtkPicture`s **with a height request** — a shrinkable one asks for no height at
|
set installed under `<datadir>/help/C/org.udpscope.app/`, not a scroll view compiled into the
|
||||||
all inside a box, so without it the page renders its text with a silent gap where the screenshots
|
binary. Its overview page shows the main window and a held capture, and the trigger and math pages
|
||||||
should be. The side column and the two panel toggles stay hidden until a
|
carry their own figures, all produced by `tools/make-help-shots.sh` and embedded as `<figure>`s. The
|
||||||
|
side column and the two panel toggles stay hidden until a
|
||||||
CONFIG arrives — there is nothing to configure before the stream describes itself —
|
CONFIG arrives — there is nothing to configure before the stream describes itself —
|
||||||
and the first CONFIG reveals the signal panel (the trigger panel stays behind its
|
and the first CONFIG reveals the signal panel (the trigger panel stays behind its
|
||||||
own toggle).
|
own toggle).
|
||||||
@@ -2258,16 +2411,42 @@ magnitudes, so consecutive differences wobble by a few ULP of the stream's uptim
|
|||||||
(femtoseconds at any plausible uptime — note that the `f32` defect this used to guard against
|
(femtoseconds at any plausible uptime — note that the `f32` defect this used to guard against
|
||||||
was *microseconds*, four orders of magnitude up).
|
was *microseconds*, four orders of magnitude up).
|
||||||
|
|
||||||
Two formats, picked by the extension the save dialog ends up with:
|
Three formats, picked by the extension the save dialog ends up with:
|
||||||
|
|
||||||
| file | what it is |
|
| file | what it is |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `.npy` | one NumPy structured array, `time`/`abs_seconds` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
|
| `.npy` | one NumPy structured array, `time`/`abs_seconds` as `<f8` and every signal as `<f4` — the width the ring stores, so the file invents no precision and is about half an all-`f8` one. `pd.DataFrame(np.load(path))` is the whole reader. |
|
||||||
| `.csv` | the same table as text, `%.17g` for the times and `%.9g` for the values: the shortest widths that round-trip each type exactly. |
|
| `.csv` | the same table as text, `%.17g` for the times and `%.9g` for the values: the shortest widths that round-trip each type exactly. |
|
||||||
|
| `.mf3`, `.mdf` | **MDF 3.30**, one channel group with every channel record-interleaved, written by hand — see *The MDF export* below. |
|
||||||
|
|
||||||
Parquet (the original ask) would have meant linking Arrow — tens of megabytes of
|
Parquet (the original ask) would have meant linking Arrow — tens of megabytes of
|
||||||
dependency for a feature whose output a three-line reader already handles.
|
dependency for a feature whose output a three-line reader already handles.
|
||||||
|
|
||||||
|
**The MDF export** is the format for handing a window to another tool, and specifically to
|
||||||
|
[ScryLab](https://docs.scrylab.de/docs/data-sources/), whose data-source contract is three
|
||||||
|
rules this file is shaped by: *every data channel becomes a signal* — the channel name is the
|
||||||
|
signal name — *the master channel of each channel group is the x axis*, and *the unit is taken
|
||||||
|
from the channel metadata*, which in MDF 3 means the channel's conversion block (`CCBLOCK`) and
|
||||||
|
nowhere else. A second occurrence of a name is dropped by that reader, so the channel names are
|
||||||
|
de-duplicated against each other and the master's.
|
||||||
|
|
||||||
|
Two things follow from that, and both differ from what the `.npy`/`.csv` table holds:
|
||||||
|
|
||||||
|
- **The master is `abs_seconds`, named `time`**, not the axis the plot showed. The relative
|
||||||
|
column is 0 at a different sample in each display mode, so a master off it would put a live
|
||||||
|
export and a held event's export on two different timelines — the one thing that column
|
||||||
|
exists to avoid. The relative axis goes out as an ordinary channel, **`time_relative`**,
|
||||||
|
right after the master, and both carry the unit `s`. The signal columns follow in the same
|
||||||
|
order the `.npy`/`.csv` file writes them.
|
||||||
|
- **The unit travels with a column**, taken from the stream's CONFIG (or a math channel's own),
|
||||||
|
and is written into the conversion block. The `.npy`/`.csv` writers are unchanged: their
|
||||||
|
readers get the names and the numbers, and their bytes are what they always were.
|
||||||
|
|
||||||
|
There is **no MDF writer library here and no new dependency**: the blocks are spelled out in
|
||||||
|
`src/export.cpp` against the field list the `asammdf` 8.8.27 reader walks the format with, and
|
||||||
|
`docs/claude/invariants-session-and-export.md` records the layout, the version decision (MDF
|
||||||
|
3.30, not MF4 — that is HDF5) and what was verified against a known-good reference file.
|
||||||
|
|
||||||
The table is collected when the menu item is clicked, not when the dialog is
|
The table is collected when the menu item is clicked, not when the dialog is
|
||||||
answered: a live view keeps scrolling while the file chooser is up. The live cut goes
|
answered: a live view keeps scrolling while the file chooser is up. The live cut goes
|
||||||
through `ScopeModel::live_window_locked()`, the same index range `publish_event()`
|
through `ScopeModel::live_window_locked()`, the same index range `publish_event()`
|
||||||
@@ -2303,7 +2482,7 @@ reaches every widget). The full key table:
|
|||||||
| `s` | show / hide the side panel |
|
| `s` | show / hide the side panel |
|
||||||
| `f` | fit to data (the plot's own key, not an application accelerator) |
|
| `f` | fit to data (the plot's own key, not an application accelerator) |
|
||||||
| `Ctrl+E` | export visible range |
|
| `Ctrl+E` | export visible range |
|
||||||
| `F1` | open the help window |
|
| `F1` | open the manual (`help:org.udpscope.app`) in the desktop help viewer |
|
||||||
| `Ctrl+/` | open the shortcut overview |
|
| `Ctrl+/` | open the shortcut overview |
|
||||||
| `Ctrl+Q` | quit |
|
| `Ctrl+Q` | quit |
|
||||||
| `+` / `-` | zoom in / out (both axes) |
|
| `+` / `-` | zoom in / out (both axes) |
|
||||||
@@ -2320,15 +2499,39 @@ The six "Plot mouse" rows (wheel, drag, right-click) carry no accelerator key
|
|||||||
syntax has no member for pointer gestures — and put the prose in the `subtitle` field instead,
|
syntax has no member for pointer gestures — and put the prose in the `subtitle` field instead,
|
||||||
which `AdwShortcutsItem` renders as free text.
|
which `AdwShortcutsItem` renders as free text.
|
||||||
|
|
||||||
**The 14-page help window** (overflow menu or F1) is a scrollable text view that opens on the
|
**The manual** (the *Documentation* overflow-menu row, or `F1`) is a Mallard help set opened in
|
||||||
Overview page, so it never comes up blank: the help pages
|
Yelp through the `help:` URI, *not* a scroll view compiled into the binary. `help/C/org.udpscope.app/`
|
||||||
are compiled into the binary as a GResource (`src/help/udpscope_help.gresource.xml`,
|
holds `index.page`, one page per subject, and the annotated screenshots the pages embed; the document
|
||||||
`--c-name=udpscope_help`) for the same reason the icons are — udpscope is run straight out of the
|
identifier `org.udpscope.app` is the desktop file's own basename, so it is the `.desktop` name, the
|
||||||
build tree with no install step, so a search path would have to be found at runtime and a missing
|
GApplication id and the install directory at once. The set is installed by an `install()` rule to
|
||||||
file fails silently. The pages are Pango markup (`.txt` bodies), validated at load; a page that
|
`share/help/C/org.udpscope.app/` — the path the freedesktop Help System Specification's `help:` URI
|
||||||
fails validation renders a one-line stand-in so the window always opens rather than crashing.
|
resolves — and `show_help()` hands `help:org.udpscope.app` to whatever the desktop registered for the
|
||||||
`UDPSCOPE_PROBE_HELP_MS` verifies that `parsed == pages` and that the `closed ok` line carries
|
scheme (Yelp on GNOME), reporting a launch failure with an alert rather than failing silently. That
|
||||||
no `Gtk-CRITICAL`.
|
alert says **which** thing is missing, because the two have different fixes: `help_installed_path()`
|
||||||
|
looks for the document under `$XDG_DATA_HOME/help/<lang>/org.udpscope.app/` then
|
||||||
|
`$XDG_DATA_DIRS/help/<lang>/org.udpscope.app/`, so with the set installed the alert names the missing
|
||||||
|
*viewer* (`yelp`), and without it the alert names the identifier, the expected installed path and the
|
||||||
|
install command — `cmake --build build --target install-help-user`, the no-root form below, which
|
||||||
|
writes the set under `$XDG_DATA_HOME` (default `~/.local/share/help/C/org.udpscope.app/`) and prints
|
||||||
|
the path it wrote. Two opt-in targets install the manual *alone*, without a
|
||||||
|
whole `cmake --install`: `install-help-user` writes that `$XDG_DATA_HOME` (default `~/.local/share`)
|
||||||
|
copy and needs no root, while
|
||||||
|
`cmake --build build --target install-help` writes to the configured `CMAKE_INSTALL_PREFIX` for a
|
||||||
|
packager or a system-wide user to wrap in `sudo`; both print where they wrote. A build tree has
|
||||||
|
neither the installed copy nor a
|
||||||
|
`help:` handler, and **either is enough to make `F1` do nothing**; that is the failure this names.
|
||||||
|
`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, and it **launches the manual too**: `[help] launch attempted=1 done=1 ok=1
|
||||||
|
error='' installed=/home/…/.local/share/help/C/org.udpscope.app` is the reading, `ok=1` meaning the
|
||||||
|
`help:` URI reached a registered handler and not that a window appeared (Yelp is another process and
|
||||||
|
cannot map one headlessly). Mallard is translatable with `itstool`, but only the `C` set is authored
|
||||||
|
here.
|
||||||
|
|
||||||
**The status bar is two tiers.** The bar itself shows one glance line: the activity badge, the
|
**The status bar is two tiers.** The bar itself shows one glance line: the activity badge, the
|
||||||
display state (one word from a fixed set — `connecting`, `live`, `paused`, `armed`, `capturing`,
|
display state (one word from a fixed set — `connecting`, `live`, `paused`, `armed`, `capturing`,
|
||||||
@@ -2397,7 +2600,15 @@ now — they are listed so they are not mistaken for anything else:
|
|||||||
cursors and the ruler for Measure, a sine crossing its trigger level and the channel list
|
cursors and the ruler for Measure, a sine crossing its trigger level and the channel list
|
||||||
for Signals (the same picture the toolbar's trigger button carries), drawn as a
|
for Signals (the same picture the toolbar's trigger button carries), drawn as a
|
||||||
32x16 pair so both motifs are legible at a glance — with the words kept as each pill's tooltip and
|
32x16 pair so both motifs are legible at a glance — with the words kept as each pill's tooltip and
|
||||||
accessible name. Each pane's heading also carries its **own switch**, the same control as the
|
accessible name. Both panes also have a **locked** mode, which is what a returning user gets: the
|
||||||
|
trigger pane shows one line saying what it is set to, the signal list shows only the signals that are
|
||||||
|
on, and neither offers a control that changes the configuration — each heading has an `[edit]` button
|
||||||
|
to switch to the full pane, and back. Dragging a signal onto a plot still works while locked — filling
|
||||||
|
plots is what the list is for, not configuration — and a locked row can be dragged from anywhere on it,
|
||||||
|
the colour chip included. In a window too narrow to show both, where the sidebar is *overlaid on* the
|
||||||
|
plot, the sidebar steps aside for the length of the drag so there is something under the pointer to drop
|
||||||
|
onto. A **first run** (no session file for that source) comes up
|
||||||
|
unlocked, because configuring the host is what a first run is for. Each pane's heading also carries its **own switch**, the same control as the
|
||||||
header bar's button — clicking either moves both — because a pane that is *disabled* while its
|
header bar's button — clicking either moves both — because a pane that is *disabled* while its
|
||||||
feature is off is a pane whose controls you would otherwise have to leave to turn it back on. The
|
feature is off is a pane whose controls you would otherwise have to leave to turn it back on. The
|
||||||
design spec had the switcher showing *titles* and neither page an
|
design spec had the switcher showing *titles* and neither page an
|
||||||
@@ -2405,8 +2616,9 @@ now — they are listed so they are not mistaken for anything else:
|
|||||||
measured), so the pages carry an empty title and the pills are named in code. With the math
|
measured), so the pages carry an empty title and the pills are named in code. With the math
|
||||||
list folded into the signal list under a `MATH` divider whose `+` creates a channel — a derived channel already *is* a channel in
|
list folded into the signal list under a `MATH` divider whose `+` creates a channel — a derived channel already *is* a channel in
|
||||||
the model, so the two lists were the same list drawn twice. A tab is one-of-*N*, so no
|
the model, so the two lists were the same list drawn twice. A tab is one-of-*N*, so no
|
||||||
toggle hides a pane any more: trigger-off and cursors-off **disable** their pane and leave
|
toggle hides a pane any more: cursors-off **disables** its readout and leaves it in place, and
|
||||||
it in place, and the measurements' and math panes' toolbar toggles are gone with the panes.
|
trigger-off leaves the trigger panel fully editable — a trigger is configured *before* it is
|
||||||
|
switched on, and the locked view says `stream (trigger off)` instead of the settings — and the measurements' and math panes' toolbar toggles are gone with the panes.
|
||||||
- **A plot holds at most eight signals** (`RowSet::kSlotsPerRow`), because a `Plot`
|
- **A plot holds at most eight signals** (`RowSet::kSlotsPerRow`), because a `Plot`
|
||||||
cannot give up a trace and the slots are therefore created up front.
|
cannot give up a trace and the slots are therefore created up front.
|
||||||
- **One toggle drives both cursor orientations.** The vertical markers and the
|
- **One toggle drives both cursor orientations.** The vertical markers and the
|
||||||
@@ -2494,6 +2706,20 @@ silence timeout and then resumes, which is the EMI case end to end.
|
|||||||
machine and its integration with the buffers, and rendering under Xvfb
|
machine and its integration with the buffers, and rendering under Xvfb
|
||||||
(llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs,
|
(llvmpipe) were exercised; the interactive paths (drag and drop, the dialogs,
|
||||||
the right-click menu) were not driven by an automated test.
|
the right-click menu) were not driven by an automated test.
|
||||||
|
- **The contextual help has no "?" on the export row.** Nine surfaces carry one;
|
||||||
|
export is a *menu action* and has no surface of its own to carry anything, and the
|
||||||
|
obvious alternative -- a "?" on the row inside the menu's popover, opening a
|
||||||
|
popover of its own -- is the nested popover the invariants already warn about: the
|
||||||
|
inner popover takes the grab, the menu closes under the click, and the bubble is
|
||||||
|
left parented to a row in a hidden popover. Export is described in the
|
||||||
|
Documentation instead.
|
||||||
|
- **The tour card can cover its own target, and in the documented headless run it
|
||||||
|
does.** The card is placed under, over or beside the hole whenever one of those has
|
||||||
|
room; when the target covers the whole content -- which is what the plot does at the
|
||||||
|
window's 756 px minimum, because the split view overlays the side panel rather than
|
||||||
|
putting it beside the plot -- no placement can avoid it, and the card takes the
|
||||||
|
bottom corner. The probe prints both rectangles instead of asserting a placement,
|
||||||
|
so the case reads as a measurement.
|
||||||
|
|
||||||
**A debug-build assertion in the library's suite.** `deps/plotter`'s debug build prints
|
**A debug-build assertion in the library's suite.** `deps/plotter`'s debug build prints
|
||||||
`TextBatch::add_cell`'s `glyph_is_cell` assertion once while running its tests — from a
|
`TextBatch::add_cell`'s `glyph_is_cell` assertion once while running its tests — from a
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
#
|
||||||
|
# The `install-help-user` target's body. Copies the Mallard help set into the user's XDG data
|
||||||
|
# directory -- where `help:<id>` resolves without root -- and prints every path it wrote.
|
||||||
|
#
|
||||||
|
# `$XDG_DATA_HOME` is honoured when it is set, and `$HOME/.local/share` is the fallback when it
|
||||||
|
# is not: that is the XDG Base Directory Specification's own stated default for the variable, and
|
||||||
|
# it is the default `help_installed_path()` falls back to as well, so the manual lands where the
|
||||||
|
# application and the desktop viewer look for it. The variable names a *data directory*, not a
|
||||||
|
# prefix -- its `share/` is not appended -- so the set goes to `<data_home>/help/C/<id>/`. An
|
||||||
|
# arbitrary `$XDG_DATA_HOME` need not end in `share`, which is exactly why this copies the tree
|
||||||
|
# rather than deferring to `cmake --install --prefix`, whose datadir is always `<prefix>/share`.
|
||||||
|
#
|
||||||
|
# Like the desktop-entry target, this runs only when it is named: `cmake --build build` never
|
||||||
|
# touches the user's data directory.
|
||||||
|
#
|
||||||
|
# Expects -DHELP_SRC= and -DHELP_NAME= on the command line.
|
||||||
|
|
||||||
|
if(DEFINED ENV{XDG_DATA_HOME} AND NOT "$ENV{XDG_DATA_HOME}" STREQUAL "")
|
||||||
|
set(data_home "$ENV{XDG_DATA_HOME}")
|
||||||
|
elseif(DEFINED ENV{HOME} AND NOT "$ENV{HOME}" STREQUAL "")
|
||||||
|
set(data_home "$ENV{HOME}/.local/share")
|
||||||
|
else()
|
||||||
|
message(FATAL_ERROR
|
||||||
|
"XDG_DATA_HOME and HOME are both unset or empty; cannot determine the user data directory")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT DEFINED HELP_SRC OR NOT DEFINED HELP_NAME)
|
||||||
|
message(FATAL_ERROR "HELP_SRC and HELP_NAME are both required")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
set(dest "${data_home}/help/C/${HELP_NAME}")
|
||||||
|
|
||||||
|
# The same filter as the install() rule, so both destinations carry the same tree: the pages and
|
||||||
|
# the figures, and nothing an editor left behind.
|
||||||
|
file(COPY "${HELP_SRC}/" DESTINATION "${dest}"
|
||||||
|
FILES_MATCHING PATTERN "*.page" PATTERN "*.png" PATTERN "*.jpg")
|
||||||
|
|
||||||
|
# Printed because there is no uninstall target: undoing this is these paths.
|
||||||
|
file(GLOB_RECURSE installed LIST_DIRECTORIES FALSE RELATIVE "${dest}" "${dest}/*")
|
||||||
|
list(SORT installed)
|
||||||
|
foreach(path IN LISTS installed)
|
||||||
|
message(STATUS "wrote ${dest}/${path}")
|
||||||
|
endforeach()
|
||||||
@@ -52,6 +52,50 @@ here is needed to *build* the application. `blueprint-compiler` and `python3` ar
|
|||||||
time; without them the target is absent rather than silently passing. `tools/check_ui.py --lint` also
|
time; without them the target is absent rather than silently passing. `tools/check_ui.py --lint` also
|
||||||
prints the linter's advice, which is advice and deliberately not a gate.
|
prints the linter's advice, which is advice and deliberately not a gate.
|
||||||
|
|
||||||
|
The manual has its own check, the same shape:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build --target test-help
|
||||||
|
```
|
||||||
|
|
||||||
|
That runs `yelp-check`'s four offline subcommands over `help/C/<id>/` (links, media, orphans, ids)
|
||||||
|
and renders the whole set with `yelp-build html` into a temp directory, so the manual is checked the
|
||||||
|
way the `.ui` files are. **`yelp-check validate` is deliberately not used**: it fetches the Mallard
|
||||||
|
RelaxNG schema over the network, which is absent on a build box, so it would fail for that reason and
|
||||||
|
not for a fault in the pages. `yelp-check`/`yelp-build` are found at configure time; without them the
|
||||||
|
target is absent rather than silently passing.
|
||||||
|
|
||||||
|
The manual is a Mallard set under `help/C/<id>/`, installed by an `install()` rule to
|
||||||
|
`<datadir>/help/C/<id>/` — where the freedesktop Help System Specification's `help:` URI resolves
|
||||||
|
`help:<id>` — so it is *read from a datadir by another process* even though the binary itself has no
|
||||||
|
install step. `F1` opens it in Yelp through that URI. The document identifier is the desktop file's
|
||||||
|
basename (`data/org.udpscope.app.desktop.in` → `org.udpscope.app`), so it is the `.desktop` name, the
|
||||||
|
GApplication id and the install directory at once. The figures are regenerated by
|
||||||
|
`tools/make-help-shots.sh`, which drives the app headlessly against the mock and draws the annotation
|
||||||
|
boxes with ImageMagick; Mallard is translatable with `itstool`, but only the `C` set is authored.
|
||||||
|
|
||||||
|
Because the manual is the only thing udpscope installs, it can be installed *alone* — where `help:`
|
||||||
|
finds it, and without the `cmake --install` of a whole project — by either of two opt-in targets.
|
||||||
|
Neither is built by default and `cmake --build build` never touches either:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cmake --build build --target install-help-user # <data-home>/help/C/<id>/, no root
|
||||||
|
cmake --build build --target install-help # <CMAKE_INSTALL_PREFIX>/share/help/C/<id>/
|
||||||
|
```
|
||||||
|
|
||||||
|
`install-help-user` writes to `$XDG_DATA_HOME` when it is set and to `$HOME/.local/share` when it is
|
||||||
|
not — the XDG Base Directory Specification's own default for the variable, and the fallback
|
||||||
|
`help_installed_path()` uses — so it needs no root and lands the set exactly
|
||||||
|
where `F1` and the alert look for it. `install-help` goes through the install machinery into the
|
||||||
|
configured `CMAKE_INSTALL_PREFIX`, so a packager or a system-wide user points it at `/usr` with
|
||||||
|
`-DCMAKE_INSTALL_PREFIX` at configure time. Neither target can install system-wide by itself: a build
|
||||||
|
target has no more privilege than the shell that ran it, so `install-help` writes to the configured
|
||||||
|
prefix and a system-wide caller wraps it in `sudo`. Both print where they wrote. Both land the same
|
||||||
|
tree the `install()` rule does — the user-local copy applies that rule's file filter, and the
|
||||||
|
prefix-wide target *is* that rule, reached through the `Help` component
|
||||||
|
(`cmake --install --component Help`) — and the ordinary `cmake --install` still installs every
|
||||||
|
component, so its behaviour is unchanged.
|
||||||
|
|
||||||
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
|
Options: `--server`, `--port`, `--multicast`, `--interface`, `--buffer-mb` (per
|
||||||
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
buffer, default 200), `--max-window`, `--holdoff`, `--recv-buffer`, `--plots` (the row
|
||||||
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards),
|
count to *start* with, 1..`RowSet::kMaxRows`; the layout editor owns it afterwards),
|
||||||
@@ -62,6 +106,19 @@ belongs with `todo.md` item 3, alongside the relative-time toggle.
|
|||||||
|
|
||||||
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
|
`tools/mock_udpstreamer.py` has waveform-injection options used by the probes:
|
||||||
|
|
||||||
|
- `--square-harmonics N` shapes `square`: `N` odd harmonics of the ideal step's Fourier
|
||||||
|
series, so each edge overshoots by the Gibbs constant (about 9 % of the 2 V swing at the
|
||||||
|
default 30, i.e. `y = ±1.179`) and then rings — a band-limited edge, which is what a
|
||||||
|
crossing count or a pulse-width test has to survive. The series is deterministic and the
|
||||||
|
odd harmonics keep the ideal wave's edge instants and its polarity, so the level-0 trigger,
|
||||||
|
the search and the glitch's inverted pulse still read where they did on the step; the
|
||||||
|
*extremes* are what move. `0` (or negative) is the ideal ±1 step, which is what a probe
|
||||||
|
asserting an exact extreme or an exact width asks for — the noise probe's group-delay leg is
|
||||||
|
the one that does, because its `want` is the analytic `tau*ln2` of a step
|
||||||
|
(see [verification.md](verification.md)). It costs the producer about 0.5 ms per
|
||||||
|
2000-sample packet on top of the ideal branch, so at the fast mock's 1.3 MS/s the mock
|
||||||
|
delivers at roughly nine tenths of its declared pace — a reading that scales with the
|
||||||
|
retained span reads that much lower, and nothing else does.
|
||||||
- `--glitch-at S` / `--glitch-width W` invert the `square` signal for `W` seconds at the
|
- `--glitch-at S` / `--glitch-width W` invert the `square` signal for `W` seconds at the
|
||||||
start of every square period from stream second `S` onwards — a runt pulse of a known
|
start of every square period from stream second `S` onwards — a runt pulse of a known
|
||||||
width, which is what a `<` pulse-width trigger and a glitch search are checked against.
|
width, which is what a `<` pulse-width trigger and a glitch search are checked against.
|
||||||
|
|||||||
@@ -94,6 +94,18 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
comparison is off by 1e9, so every packet would read as an implausible jump. The guard is
|
comparison is off by 1e9, so every packet would read as an implausible jump. The guard is
|
||||||
`tools/mock_udpstreamer.py --time-glitch-at`/`--time-glitch-by` (see
|
`tools/mock_udpstreamer.py --time-glitch-at`/`--time-glitch-by` (see
|
||||||
[verification.md](verification.md)).
|
[verification.md](verification.md)).
|
||||||
|
- **A *restarted* client is the other way into the same poisoning, and it is not a glitch.** Going live
|
||||||
|
from a file view starts a new `udps_client`, and the new stream's stamps can sit **behind** the ones the
|
||||||
|
old stream left in `last_stamp_` — the file view's samples are still in the store. The advance test then
|
||||||
|
rejects every packet of a perfectly good stream, four rejections abandon the declared time signal, and
|
||||||
|
the arrival clock derives its epoch from the stale mark: every sample is stamped **flat at the old
|
||||||
|
value**. `trig engine: … ring=[2.5200..2.5200] n=567000 → n=1131000 → n=1689000` is what that looks
|
||||||
|
like — the count climbing at the stream's rate while the retained span is one value — until some later
|
||||||
|
rebuild resets the store, which is where the "few seconds of nothing" came from.
|
||||||
|
`Acquisition::start()` therefore calls `ScopeModel::reset_stream()` and re-bases `last_stamp_` to
|
||||||
|
`-infinity`: a started or restarted client is a *new stream*, so the previous one's history goes (which
|
||||||
|
is what `reset()` is for) and the monotonic guard re-bases on the new clock instead of pinning it.
|
||||||
|
`ring` moving within a stream round-trip of *Go live* is the assertion.
|
||||||
- **The arrival-clock fallback does not drop history.** `arrival_epoch_` is derived from
|
- **The arrival-clock fallback does not drop history.** `arrival_epoch_` is derived from
|
||||||
`last_stamp_`, so the arrival clock *continues* from the monotonic mark and the two
|
`last_stamp_`, so the arrival clock *continues* from the monotonic mark and the two
|
||||||
timelines share a zero by construction — the recorded stamps are therefore still on one
|
timelines share a zero by construction — the recorded stamps are therefore still on one
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Invariants: GTK traps, the status bar, the icons and the help
|
# Invariants: GTK traps, the status bar, the icons and the manual
|
||||||
|
|
||||||
The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and the help pages.
|
The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and how the Mallard manual is installed and opened.
|
||||||
|
|
||||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||||
|
|
||||||
@@ -111,19 +111,26 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
|
tick updates it, so a rebuild is a per-frame relayout of a widget on screen.
|
||||||
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
|
`update_status_popover()` only ever calls `gtk_label_set_text()`, and `popover_rebuilds` in
|
||||||
the dump is the assertion.
|
the dump is the assertion.
|
||||||
- **The About dialog, the help window and the shortcut overview are opened by `show_about()`,
|
- **The About dialog and the shortcut overview are opened by `show_about()` and `show_shortcuts()`,
|
||||||
`show_help()` and `show_shortcuts()`, and each closes with the matching call.** There is
|
and each closes with the matching call; the manual is *not* one of these.** `show_help()` hands
|
||||||
exactly **one `GtkWindow`** among the three — the paged help window. It closes with
|
the `help:<document>` URI to the desktop help viewer (`GtkUriLauncher`, i.e. Yelp) — a process of
|
||||||
|
its own that cannot be closed from here — because the manual is a Mallard set installed for the
|
||||||
|
freedesktop `help:` URI, not a GResource in this binary (see the help bullet below). So there is
|
||||||
|
exactly **one `GtkWindow`** among what the app opens: the shortcut overview. It closes with
|
||||||
`gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope
|
`gtk_window_close()`, which emits `close-request` so the handler that nulls the file-scope
|
||||||
pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the
|
pointer runs. `gtk_window_destroy()` does **not** emit that signal and must not be used — the
|
||||||
math dialog fell into exactly this trap, and the pointer then points at a dead widget until the
|
math dialog fell into exactly this trap, and the pointer then points at a dead widget until the
|
||||||
next attempt to open the same window. The other two — the About dialog (an `AdwAboutDialog`)
|
next attempt to open the same window. The About dialog (an `AdwAboutDialog`) is an **`AdwDialog`
|
||||||
and the shortcut overview (an `AdwShortcutsDialog`) — are **`AdwDialog`s and not `GtkWindow`s**:
|
and not a `GtkWindow`**: it closes with `adw_dialog_close()` and clears on `::closed`, there being
|
||||||
they close with `adw_dialog_close()` and clear on `::closed`, there being no `::close-request`
|
no `::close-request` on an `AdwDialog`. It shares one `on_dialog_closed()` handler with the About
|
||||||
on an `AdwDialog`. Both share one `on_dialog_closed()` handler keyed by a **pointer to the
|
pointer, keyed by a **pointer to the pointer** it must null (`&g_about`), which keeps the handler
|
||||||
pointer** it must null (`&g_about`, `&g_shortcuts`), which is what keeps one handler honest for
|
honest without knowing which dialog it is. One `GtkWidget*` (or `AdwDialog*`) per window, nulled
|
||||||
two dialogs. One `GtkWidget*` (or `AdwDialog*`) per window, nulled in its own close handler, is
|
in its own close handler, is the shape that prevents the double-open defect. `close_help_windows()`
|
||||||
the shape that prevents the double-open defect across all three.
|
(misnamed now) closes just the two dialogs; a failed *launch* is reported by the launcher's own
|
||||||
|
callback with a `GtkAlertDialog`, so a click that cannot open the manual says so. That callback is
|
||||||
|
also `show_help()`'s **optional reporter** (`std::function<void(bool ok, const std::string& error)>`)
|
||||||
|
— the one hook that runs on the success path, where no window of ours is left to observe; the probe
|
||||||
|
uses it and nothing else does.
|
||||||
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at
|
- **`build_info.cpp` is generated, never committed.** `cmake/write_build_info.cmake` runs at
|
||||||
configure time (so `add_executable` can name the file) and again on every build through
|
configure time (so `add_executable` can name the file) and again on every build through
|
||||||
`udpscope_build_info` (so the commit and the timestamp belong to the binary that is running).
|
`udpscope_build_info` (so the commit and the timestamp belong to the binary that is running).
|
||||||
@@ -137,15 +144,85 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special
|
arrives with `Mod::Shift` set. Ctrl+Alt together zooms neither axis, which needs no special
|
||||||
case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y`
|
case. The guards are `deps/plotter`'s own `ctrl_plus_zooms_only_x` and `alt_plus_zooms_only_y`
|
||||||
tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`.
|
tests, plus `UDPSCOPE_PROBE_ZOOMKEYS_MS`.
|
||||||
- **A help page is one `.txt`, one `<file>` line in the GResource manifest, and one `kPages`
|
- **A help page is a Mallard file, and the manual is *not* in the binary.** `help/C/<id>/` holds
|
||||||
row.** The bodies are Pango markup in a second GResource (`--c-name=udpscope_help`, so its
|
`index.page`, one `<topic>.page` per subject, and the `figures/*.png` the pages link, and the
|
||||||
generated symbols cannot collide with the icons'), validated with `pango_parse_markup()` at
|
whole set is installed (the `install()` rule in CMakeLists) to `<datadir>/help/C/<id>/` — the
|
||||||
load; a page that fails either lookup or parse renders a one-line stand-in and is excluded from
|
path the freedesktop Help System Specification's `help:` URI resolves `help:<id>` to. The
|
||||||
`parsed`. `UDPSCOPE_PROBE_HELP_MS`'s `parsed` must equal `pages`.
|
document identifier is *derived from the desktop file's basename* (`org.udpscope.app.desktop.in`
|
||||||
A page may also carry screenshots (`HelpPage::images`, a null-terminated list of resources).
|
→ `org.udpscope.app`), so it is the `.desktop` name, the GApplication id and the install
|
||||||
They are `GtkPicture`s **with a height request**: one created `can_shrink` asks for *no* height
|
directory at once; `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that one
|
||||||
at all inside a box, so without the request it is allocated nothing and the page renders its text
|
place. `show_help()` opens `help:<id>` with `GtkUriLauncher` (Yelp runs in its own process, so
|
||||||
with a silent gap where its screenshots should be — which is what the first version did.
|
nothing here holds a pointer to it), and its failure callback shows a `GtkAlertDialog`. That
|
||||||
|
failure is **careful about which thing is missing**: `gtk_uri_launcher_launch_finish()` reports a
|
||||||
|
launch that could not be handed over — the *handler's* absence — and cannot tell that from a
|
||||||
|
document that was never installed, so `help_installed_path()` walks the specification's own
|
||||||
|
`$XDG_DATA_HOME/help/<lang>/<id>/` then `$XDG_DATA_DIRS/help/<lang>/<id>/` for an `index.page` and
|
||||||
|
the dialog says which one it is: with the manual there, install a viewer (for example `yelp`); with
|
||||||
|
it absent, the identifier and the install command, the **system-wide** form first
|
||||||
|
(`cmake -DCMAKE_INSTALL_PREFIX=/usr .. && sudo cmake --build build --target install-help`), the
|
||||||
|
no-root `install-help-user` second with its caveat (see the location rule below) — and a
|
||||||
|
viewer as well, the second possibility the error alone cannot rule out. Reporting a missing
|
||||||
|
*document* as a missing *handler* is the defect that shape avoids.
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_HELP_MS` launches it too, and its `[help] launch attempted= done= ok= error=''
|
||||||
|
installed=` line is the reading — `ok=1` is the URI handed to a viewer and not a window on screen,
|
||||||
|
since Yelp is another process and cannot map one headlessly; `installed=` sits beside it because a
|
||||||
|
failing `ok=0` has to be read against whether the document was there at all.
|
||||||
|
|
||||||
|
`UDPSCOPE_PROBE_HELP_MS` no longer parses pages — they are files, not markup in a resource — so
|
||||||
|
its `[help]` line asserts the set is *there*: `topics` (the `.page` files other than
|
||||||
|
`index.page`), `index`, and `figures`, scanned from `UDPSCOPE_HELP_DIR`. `topics=0 index=0` means
|
||||||
|
the manual is missing from the tree, which is the finding the old `parsed != pages` used to name.
|
||||||
|
`cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files:
|
||||||
|
`yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render.
|
||||||
|
`yelp-check validate` is deliberately absent — it fetches the Mallard RelaxNG schema over the
|
||||||
|
network and fails offline for that reason, not for a fault in the pages. The figures come from
|
||||||
|
`tools/make-help-shots.sh`, which drives the app headlessly against the mock and annotates a grab
|
||||||
|
(GTK4 must be forced to the Cairo renderer, `GSK_RENDERER=cairo`, or the X11 surface reads back
|
||||||
|
blank). Mallard is translatable with `itstool`, but there is no i18n set-up here: only the `C`
|
||||||
|
set is authored.
|
||||||
|
|
||||||
|
**A user-local help set is one Yelp cannot draw the figures of, so the system-wide install is
|
||||||
|
the one to recommend — and this is a *location* rule, not a markup or a file rule.** Yelp
|
||||||
|
sandboxes its WebKit subprocess, and that sandbox exposes the system help directories
|
||||||
|
(`/usr/share/help`, `/usr/local/share/help`) but not `$XDG_DATA_HOME/help`: the pages load,
|
||||||
|
their `<media>` figures do not, because each figure is a *separate file* the page references
|
||||||
|
rather than something embedded in it. The symptoms read as anything but a location problem —
|
||||||
|
opening a page *as a file* renders it with no images, and opening it through the app's `help:`
|
||||||
|
URI leaves the viewer spinning **for ever**, the failed image fetch meaning the page's load
|
||||||
|
event never fires while the sidebar title still updates, which looks like a navigation failure
|
||||||
|
rather than a missing resource. A shipped GNOME page that carries a figure
|
||||||
|
(`/usr/share/help/C/accerciser/api_browser_plugin.page`) draws either way, which is what
|
||||||
|
isolates the cause to where the set *lives*. `make install-help` (into `/usr/local`) followed
|
||||||
|
by `yelp /usr/local/share/help/C/<id>/<topic>.page` works; the same set under
|
||||||
|
`~/.local/share/help` does not. Two genuine defects were chased and fixed before the location
|
||||||
|
was found — a `<figure>` with no `<desc>` and a `bKGD` chunk the PNG spec forbids on an
|
||||||
|
alpha image — so do not read a figure that will not draw as a repeat of either, and do not
|
||||||
|
revert those fixes: this is why both the failure alert in `src/help_window.cpp` and the
|
||||||
|
manual's own `overview.page` name the system-wide install first and state the user-local one's
|
||||||
|
caveat beside it.
|
||||||
|
- **The chooser carries the same help affordances as the window, and the tour request crosses as a
|
||||||
|
value — not a global.** The source chooser runs *before* `App` exists, so a user who has not started
|
||||||
|
yet is the one least able to find the help; its header bar therefore has a help `GtkMenuButton`
|
||||||
|
(the `.ui` holds the button, code holds the rows) whose three entries are the window's Help view's
|
||||||
|
the same way — `ui_impl::menu_button()`/`menu_toggle()`, the app's own row shape — with the
|
||||||
|
`.udpscope-menu` class on the popover. That class's rules only exist once `install_css()` has run, so
|
||||||
|
`choose_source()` calls it before building the rows (`install_css()` is idempotent precisely because
|
||||||
|
it now has two callers; the app's `on_activate()` still calls it and is a no-op by then). The rows
|
||||||
|
show **no accelerators**: the window's Help view prints `F1`/`Ctrl+T` because the window binds them,
|
||||||
|
and this dialog binds none, so a key printed here would be documentation that does nothing.
|
||||||
|
*Documentation* calls `show_help()` straight from the chooser (the manual is another process and
|
||||||
|
needs nothing of the app); *Enable contextual help* writes the settings file, which is what the app
|
||||||
|
reads when it starts a moment later, and **re-syncs this dialog's own "(i)" rows** through
|
||||||
|
`chooser_help_sync()`; *Guided tour* starts a tour **of this window** -- the chooser's own walk --
|
||||||
|
and that walk's last stop sets **`Config::tour_pending`**, the one value `main()` owns and hands to
|
||||||
|
both `choose_source()` and `run_ui()` (`open_waveform` and the preferences' relative time cross the
|
||||||
|
same way). `on_activate()` schedules `tour_start_requested()` 1.5 s later, the same delay the
|
||||||
|
first-run auto-start uses, and **that callback is separate from `tour_autostart()` on purpose**: it
|
||||||
|
sets `tour_requested`, not `tour_auto_started`, so `help layer:` says which of the two started the
|
||||||
|
window's half instead of conflating a user's request with a first run. The chooser's half has its own
|
||||||
|
entry points in `source_dialog.cpp` (`chooser_tour_begin`, `chooser_tour_autostart`), and *that* walk
|
||||||
|
is what a first run meets first -- see the tour bullets in the help-affordance section below.
|
||||||
- **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.**
|
- **A `GtkPaned` hands its start child at least its minimum unless the position says otherwise.**
|
||||||
Putting the events pane in a paned is what makes its width the user's to drag -- but with the
|
Putting the events pane in a paned is what makes its width the user's to drag -- but with the
|
||||||
position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column
|
position left alone, `shrink-start-child` or not, the pane's minimum is reserved as an empty column
|
||||||
@@ -166,9 +243,13 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
heights, so one shared scroller would leave a switch scrolled where the new page never asked to
|
heights, so one shared scroller would leave a switch scrolled where the new page never asked to
|
||||||
be). The reason is the one this trap teaches: five sections competing for one 320 px column left
|
be). The reason is the one this trap teaches: five sections competing for one 320 px column left
|
||||||
every one of them short of room. A *feature's* on/off is no longer a visibility at all -- the
|
every one of them short of room. A *feature's* on/off is no longer a visibility at all -- the
|
||||||
trigger's and the cursors' toggles set their pane's **body** insensitive, so the pane stays where it
|
**cursors'** toggle sets its pane's **body** insensitive -- the readout has nothing to show with the
|
||||||
is and the settings you are about to switch back on do not vanish while you look for them. The *body*
|
instruments off -- and the *body* and not the pane, because the pane's heading carries its own switch
|
||||||
and not the pane, because each pane's heading carries its own switch (see the next bullet). The
|
(see the next bullet). The **trigger's body is deliberately never disabled**: a trigger is configured
|
||||||
|
*before* it is switched on, so switching it off to change it, or to read what it is set to, is the
|
||||||
|
ordinary way to work. It used to go insensitive with the mode, which left the settings unreachable
|
||||||
|
exactly when a user wanted to change them; `trigger panel sensitive …=1` is the regression guard, and a
|
||||||
|
`0` there means the configuration has been locked out again. The
|
||||||
trap itself does not go away with the widget: a stack's hidden page measures 0x0 too and is still
|
trap itself does not go away with the widget: a stack's hidden page measures 0x0 too and is still
|
||||||
given the leftover width, so **ask about state, not about size** carries over unchanged. Every
|
given the leftover width, so **ask about state, not about size** carries over unchanged. Every
|
||||||
allocation read in the sidebar is a debug capture, and each reads 0 on the page that is *not*
|
allocation read in the sidebar is a debug capture, and each reads 0 on the page that is *not*
|
||||||
@@ -184,6 +265,20 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
**tooltip and accessible name** from the page's name, then hides the empty label that would otherwise
|
**tooltip and accessible name** from the page's name, then hides the empty label that would otherwise
|
||||||
leave the icon off-centre. An icon-only pill is still a named control for a screen reader, and
|
leave the icon off-centre. An icon-only pill is still a named control for a screen reader, and
|
||||||
`switcher: … pills=2 labels=0 names=2` is the reading for all three facts at once.
|
`switcher: … pills=2 labels=0 names=2` is the reading for all three facts at once.
|
||||||
|
- **An *overlaid* sidebar covers the plot, so a drag out of it has nowhere to land.** `collapsed` in an
|
||||||
|
`AdwOverlaySplitView` means the sidebar sits *on* the plot rather than beside it: the pointer is over the
|
||||||
|
list, never over a drop target, and a signal can be dragged but not dropped. `row_drag_sidebar()` hides
|
||||||
|
the sidebar on `drag-begin` and restores it on `drag-end` — only when collapsed, because side by side the
|
||||||
|
plot is already visible and hiding the list the drag started in would be startling. `pane drag: sidebar
|
||||||
|
hidden for the drag=1 restored after it=1` is the reading; the state is forced collapsed the way the
|
||||||
|
sidebar probe forces it.
|
||||||
|
- **A stock icon name has to be one *Adwaita* has, and a blank button is the failure mode.** The two
|
||||||
|
panes' `[edit]` buttons are `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic`
|
||||||
|
(the check: done). The done one was `emblem-ok-symbolic`, which breeze ships and **Adwaita does not** —
|
||||||
|
so the button that leaves configuration mode was blank, which no other reading could see: the button
|
||||||
|
worked, the layout was unchanged, and only `pane edits:` (the two names and
|
||||||
|
`gtk_icon_theme_has_icon()`'s verdict, clicked into each mode) would say so. Check a name against
|
||||||
|
`/usr/share/icons/Adwaita` before using it; a name found under `breeze` is not an answer.
|
||||||
- **A pane's heading switch and the header bar's button are one control in two places.** Each pane's
|
- **A pane's heading switch and the header bar's button are one control in two places.** Each pane's
|
||||||
heading (`build_trigger_panel()` builds the trigger's, `cursors-panel.ui` has the cursors' in its
|
heading (`build_trigger_panel()` builds the trigger's, `cursors-panel.ui` has the cursors' in its
|
||||||
`head`) carries a `GtkSwitch` that runs the *same action* the toolbar button runs, and
|
`head`) carries a `GtkSwitch` that runs the *same action* the toolbar button runs, and
|
||||||
@@ -427,3 +522,156 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
is why every documented probe (all of them pass `--server`) is on exactly the path it was before --
|
is why every documented probe (all of them pass `--server`) is on exactly the path it was before --
|
||||||
and a `--server` run never had a window to lose, so the post-present notice is the right place for it.
|
and a `--server` run never had a window to lose, so the post-present notice is the right place for it.
|
||||||
`loading_hide()` is a no-op when none is up, so `run_ui()` needs no notion of which path it is on.
|
`loading_hide()` is a no-op when none is up, so `run_ui()` needs no notion of which path it is on.
|
||||||
|
|
||||||
|
## The help affordance layer
|
||||||
|
|
||||||
|
The `Help` submenu, the contextual help's "?" and the guided tour, and the traps each of them met.
|
||||||
|
|
||||||
|
- **`gtk_widget_measure()` includes the widget's own margins.** Measured: a box whose natural width
|
||||||
|
is 280 and whose `margin-start` is 453 reports `natural = 733`. That matters wherever a measure is
|
||||||
|
used to *place* the widget and the margin *is* the placement: the width the widget was placed at
|
||||||
|
comes back as its width, and the card of the guided tour walked between the bottom-right corner and
|
||||||
|
the left of the target from frame to frame because of it (a `place_natural()` that set no size
|
||||||
|
request did not help -- the margin alone was the loop). Take the margins off a measure that feeds a
|
||||||
|
position back. `hdl`/`bd`-style CSS padding is *not* part of it, and neither is the other axis's
|
||||||
|
margin, so it is `margin-start + margin-end` on the horizontal and top/bottom on the vertical.
|
||||||
|
- **A widget's allocation is not `gtk_widget_get_width()`, and a measure is never a position.** GTK's
|
||||||
|
`gtk_widget_get_width()`/`get_height()` return the widget's **content** box: `size_allocate`
|
||||||
|
subtracts the CSS border and padding before storing them (measured on the tour card, which carries
|
||||||
|
`border 1px` + `padding 12px`: `get_width()` reads 254 where the rectangle on screen is 280), so a
|
||||||
|
card *placed* from it overhangs its own size and covers the target it is meant to sit beside.
|
||||||
|
`gtk_widget_compute_bounds()` is that rectangle -- border and padding in, margins out -- and is what
|
||||||
|
the probe's geometry and the help's figures already read. And `gtk_widget_measure()` cannot stand in
|
||||||
|
for it: its size includes the widget's margins (the bullet above), and asking for a **height with a
|
||||||
|
width** (`for_size`) on a widget whose margins were then subtracted from that width makes GTK warn
|
||||||
|
`Trying to measure GtkBox ... for width of N, but it needs at least M` **once per frame**, because
|
||||||
|
the for_size lands one margin short of the minimum the widget answers with. The guided tour's card
|
||||||
|
therefore takes its size from `compute_bounds()` once a frame has run and falls back to a measure
|
||||||
|
**with no for_size at all** (`-1`, the form GTK measures without that check) only on a frame with
|
||||||
|
nothing allocated yet. The warning reaches no probe -- GTK is built with **structured logging**, so
|
||||||
|
it goes through the log *writer* and never `g_log_set_handler()`/`g_log_set_default_handler()` --
|
||||||
|
which is why the exit dump counts GTK's own warnings through a writer function (`gtk warnings:`,
|
||||||
|
which must read 0) as the one reading that can assert the class away.
|
||||||
|
- **The overflow menu's `Help` submenu is a two-page `GtkStack`, and neither of the obvious
|
||||||
|
alternatives works.** Rebuilding the rows -- the plot menu's own shape -- destroys
|
||||||
|
`app.layout_toggle`, which points into the main view and which `sync_layout_editor()` and the `L`
|
||||||
|
accel read; swapping the *popover's child* instead leaves whichever view is not showing with the
|
||||||
|
popover as its only owner, so it is finalized the moment it is swapped out. The popover's child is a
|
||||||
|
stack, the visible child is the view, and `gtk_popover_present()` re-measures a popover that is
|
||||||
|
already up. `UDPSCOPE_PROBE_MENUS_MS` walks the *visible page*, which is the one line the stack cost
|
||||||
|
the probe.
|
||||||
|
- **A "(i)" joins its surface's row as a sibling, not as an overlay child.** An overlay child
|
||||||
|
sits *on* whatever is under it -- and every panel heading already has a switch or a pencil in a
|
||||||
|
corner. `src/ui_context_help.cpp` is a table of (id, title, body, shortcut, host) where the host is
|
||||||
|
the *row* -- a panel's heading usually, and the row of a specific control (the "record events"
|
||||||
|
switch, the pre-trigger share, the search block's expander *header*) where that is the thing being
|
||||||
|
explained. The one exception is the
|
||||||
|
plot, which has no heading: its "(i)" is an overlay child of the overlay the layout editor already
|
||||||
|
uses, and its corner is free. The icon is **`dialog-information-symbolic`** -- `help-about-symbolic`
|
||||||
|
is *already* the status bar's information button, and two "help" glyphs meaning different things on
|
||||||
|
the same line is the trap the seven app icons exist to avoid; the dump reads the name back through
|
||||||
|
the theme for the same reason the pane [edit] buttons do. `math_head` exists only while the math
|
||||||
|
editor is open, so that row is skipped when it is null.
|
||||||
|
- **A `GtkMenuButton` ignores both `min-width` and a size request, and the icon is a node *below*
|
||||||
|
its `button` subnode.** The "(i)" is meant to be a *mark*, not a button, and the default is 34x34 (a
|
||||||
|
24 px floor plus 5 px of padding from Adwaita's `button` rule). Measured on libadwaita 1.10: setting
|
||||||
|
`min-width`/`min-height` on the widget changed nothing, and neither did `gtk_widget_set_size_request()`
|
||||||
|
-- the menu button's own measure wins. The node tree is `menubutton > button > box > image` (read off
|
||||||
|
the CSS node dump), so `install_css()` reaches the glyph as
|
||||||
|
`menubutton.udpscope-hint > button > box > image`. The direct-grandchild form `> button > image`
|
||||||
|
matches **nothing** and is silent about it -- the glyph keeps its natural 16 px, which is why an
|
||||||
|
earlier version shipped a 16x16 mark and took that for the floor it was not. Zeroing the outer node
|
||||||
|
and the `button` subnode and asking the glyph for **12 px** (`min-width`/`min-height` *and*
|
||||||
|
`-gtk-icon-size`, the latter what actually *scales* the drawing rather than clamping its box) gives
|
||||||
|
**12x12**. (A class on the outer node alone leaves the subnode at 34; a class on the subnode alone
|
||||||
|
shrank the width to 16 and left the height at 26.) There is no lower floor: the symbolic glyph renders
|
||||||
|
at any request (measured down to 4 px), so 12 px is a legibility choice and not a limit. The dump's
|
||||||
|
`hint=` reading is that number, measured with `gtk_widget_compute_bounds()` on the first *visible*
|
||||||
|
hint -- a hint inside a collapsed `GtkExpander`'s *child* is not in the widget-parent chain at all and
|
||||||
|
reads 0x0, which would look like a mark shrunk to nothing. That is why the search block's hint rides
|
||||||
|
the expander's **header** (a `label-widget` box, `search_head` in `src/ui/trigger-panel.ui`, which
|
||||||
|
*is* in the tree) rather than its child, and why the `[helpui] collapsed search:` reading asserts the
|
||||||
|
header hint is mapped and 12x12 while the block is collapsed.
|
||||||
|
- **Off is gone, not greyed, and the buttons are found by a data key rather than kept in a list.** The
|
||||||
|
list is what the math editor cannot survive: its heading lives in a builder that is dropped and
|
||||||
|
rebuilt on every opening, so a stored pointer into it would go stale one dialog later. A key on the
|
||||||
|
button (`udpscope-help-hint`, carrying the surface's id) is both the mark that says "this is a hint"
|
||||||
|
and the name the probe and the dump report it by, and removing a hint is `gtk_widget_unparent()`,
|
||||||
|
which means the same thing for a box and for the plot's overlay. The button itself is
|
||||||
|
`ui_impl::hint_button()` reached either by the table's `context_help_sync()` or by
|
||||||
|
`context_help_button()` for a window with no `App` (the source chooser) -- one button, two callers,
|
||||||
|
so the two windows cannot drift into two affordances.
|
||||||
|
- **The guided tour takes no grab, and that is the whole reason it is an overlay.** Four dim
|
||||||
|
rectangles around the hole are ordinary widgets that swallow the clicks that land on them and
|
||||||
|
nothing else; `Skip`, `Finish` and `Escape` hide them, and there is no popover, no gesture and no
|
||||||
|
grab to leak. The probe asserts it as a reading -- `controllers=` against the count taken before the
|
||||||
|
tour opened -- rather than as a claim, because "no lingering grab" is exactly the sort of thing a
|
||||||
|
later change can add back without noticing.
|
||||||
|
- **The tour is one implementation over two windows.** `ui_impl::TourView` holds the overlay children
|
||||||
|
and the walk's place in it (`tour_build`/`tour_begin`/`tour_advance`/`tour_end`/`tour_update`), and
|
||||||
|
what differs between the windows is only the *stops*: a `TourStop` names a widget the owner resolves,
|
||||||
|
so each window keeps its own table -- the main window's against `App` fields,
|
||||||
|
`source_dialog.cpp`'s `kChooserStops` against the `Chooser`'s. A walk that passes its last stop calls
|
||||||
|
the view's `on_done`; the window's passes null, the chooser's sets `Config::tour_pending`, which is
|
||||||
|
how the two halves are one tour. `App` carries its own `TourView` (`app.tour`), so the machinery is
|
||||||
|
compiled once and the probe reads either window's state by the same fields.
|
||||||
|
- **A stop declares the page it stands on, and the tour selects that page as the stop is entered.** A
|
||||||
|
stop whose target lives on another page of a stack -- the window's `signals` (and `trigger`) stops
|
||||||
|
on the sidebar's `signals` page, the chooser's "Two kinds of input" stop on its `waveforms` page --
|
||||||
|
names that page in `TourStop::page`, and `show_step()` selects it from `TourView::page_stack`
|
||||||
|
before anything is measured. The page switch is **not** followed by a one-shot measure: `show_step()`
|
||||||
|
selects the page and `tour_update()`, which already runs every frame, fills the spotlight in on the
|
||||||
|
frame *after*, when the new page has been laid out. A stack allocates the new page's widgets on the
|
||||||
|
frame after the switch, so a measurement taken in the switch's own tick reads zero (the allocation
|
||||||
|
bullet in [invariants-trigger-and-pinning.md](invariants-trigger-and-pinning.md)) -- and reading the
|
||||||
|
page *name* back is fine immediately, because that is state and not geometry.
|
||||||
|
- **The stop filter runs on `get_mapped()`, not `is_visible()`, and that is what separates a wrong
|
||||||
|
page from a collapsed pane.** GTK 4.24's `gtk_widget_is_visible()` walks only the widgets' own
|
||||||
|
`visible` flags up the parents; it does **not** consult `child_visible`. Both a `GtkStack`'s
|
||||||
|
non-current pages and `AdwOverlaySplitView`'s hidden sidebar are hidden with
|
||||||
|
`gtk_widget_set_child_visible()`, so a widget on another page, *and* one in a collapsed pane, both
|
||||||
|
read `is_visible() == TRUE` (verified against 4.24.1) -- the old filter kept a stop it meant to
|
||||||
|
drop and left the spotlight on a target with no allocation. `stop_is_walkable()` keeps a stop when
|
||||||
|
its target is **mapped**, or when it names a page and the stack that page belongs to is mapped: a
|
||||||
|
wrong page leaves the stack mapped, so the stop is kept and the page switch reveals it; a collapsed
|
||||||
|
pane leaves the stack unmapped too, so no page can reveal it and the stop is dropped. The filter
|
||||||
|
lives in `tour_begin()`, not in each window's `start_tour()`, both so the two windows cannot drift
|
||||||
|
into two rules and because the rule needs the view's own `page_stack`.
|
||||||
|
- **The chooser's tour needs an overlay over its *whole* window, and `AdwWindow`'s content is
|
||||||
|
replaced, not wrapped.** Two of the chooser's stops name header-bar buttons, and an overlay inside
|
||||||
|
the `AdwToolbarView`'s `content` would give them negative coordinates and clamp the hole to the top
|
||||||
|
edge. So the overlay *becomes* the window's content and adopts the toolbar view -- and the order
|
||||||
|
matters: `gtk_overlay_set_child()` refuses a child that still has a parent, so
|
||||||
|
`adw_window_set_content(win, overlay)` goes **first** (that call is what unparents the toolbar view)
|
||||||
|
and `gtk_overlay_set_child(overlay, content)` second. The builder's object list still owns the view,
|
||||||
|
so nothing is finalized in between. Getting the order wrong is a `Gtk-CRITICAL` and an empty
|
||||||
|
chooser, which is exactly what the first version of this did.
|
||||||
|
- **The chooser's hints are its own table because it runs before `App`.** An `AdwEntryRow` is not a
|
||||||
|
box and its editor is internal, so a hint cannot join the host/port fields as a sibling -- appending
|
||||||
|
it to the `AdwPreferencesGroup` would make it a row under the field rather than a mark beside it, so
|
||||||
|
no hint is invented for them and the group's own `description` carries that text. The two rows the
|
||||||
|
dialog *does* have -- the heading above the saved-source list and the waveforms page's action row --
|
||||||
|
carry `chooser_sources` and `chooser_waveform`, added with the same `context_help_button()` the
|
||||||
|
window uses and re-synced by its Help menu's toggle.
|
||||||
|
- **The hole is kept from the last frame that could measure the target.** `gtk_widget_compute_bounds()`
|
||||||
|
answers nothing on a frame that lands between two layout passes, and letting the hole empty there
|
||||||
|
made the spotlight and the card jump to the corner and back; a *target that is not mapped* is the
|
||||||
|
different case (a genuinely absent surface) and is what empties it. The placement is also
|
||||||
|
re-measured on **every frame**, never once at the start: a widget's allocation is 0 on the frame it
|
||||||
|
is first shown, and a window resize moves it under a one-shot measurement.
|
||||||
|
- **The first-run tour is a `settings.toml` key (`tour_seen`), written when the tour *starts*.** Not
|
||||||
|
"the settings file did not exist": a user who has already changed the theme has a file and has still
|
||||||
|
never seen the tour. Written on the way *in*, because a user who skips it in the first second has
|
||||||
|
still seen it. It starts **in the source chooser** when one is shown (the chooser is the surface a
|
||||||
|
first run meets first, and the window's own auto-start is unreachable until the chooser has returned
|
||||||
|
with a source) and in the window otherwise; either way it is a 1.5 s timer, so the window is up and
|
||||||
|
laid out before anything points at it. The chooser's walk writes the key itself and hands the second
|
||||||
|
half over by `Config::tour_pending`, so a first run is *one* tour that crosses the two windows.
|
||||||
|
- **The `help figures:` line is captured on the tick, like every other size the dump prints.** It names
|
||||||
|
ten surfaces' window-coordinate rectangles for the help's annotated figures, and the dump runs after
|
||||||
|
the window is destroyed -- a `gtk_widget_compute_bounds()` there is the "no `gtk_widget_*` call in the
|
||||||
|
dump" rule again. `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger
|
||||||
|
summary while the pane is in configuration mode -- keeps its own flag true and reads 0x0, and a crop
|
||||||
|
box drawn from that is a box over the wrong thing. Its numbers depend on the *window's* size, which
|
||||||
|
under the documented `xvfb-run` is the window's 756x894 minimum (a 640x480 screen), so a run on a
|
||||||
|
larger screen gives different rectangles for the same panels.
|
||||||
@@ -16,9 +16,10 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
frame; the window is materialized in bounded chunks (never two whole-window f32 temporaries)
|
frame; the window is materialized in bounded chunks (never two whole-window f32 temporaries)
|
||||||
and the loop holds it **by reference** (copying both vectors per measurement was 300 MB of
|
and the loop holds it **by reference** (copying both vectors per measurement was 300 MB of
|
||||||
memcpy a frame); and `measure_stats()` is one shared pass carrying both extremes with their
|
memcpy a frame); and `measure_stats()` is one shared pass carrying both extremes with their
|
||||||
first positions, the sum and the count, so average/min/max/peak-to-peak/amplitude need no
|
first positions, the sum and the count, so average/min/max/peak-to-peak need no pass of their
|
||||||
pass of their own. **Adding a type must not add a pass** -- that is what `compute_measure()`'s
|
own -- `top`/`bottom`/`amplitude` take a second read of that same **bucket** list and never of
|
||||||
`stats` argument is for.
|
the window (the flat-level invariant below). **Adding a type must not add a pass** -- that is
|
||||||
|
what `compute_measure()`'s `stats` argument is for.
|
||||||
The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and
|
The marks' *ink* is set where they are created (`install()` in `plot_host.cpp`): 0.75 px and
|
||||||
dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker
|
dashed 4/4 through `Plot::set_cursor_stroke()`, which is per cursor because the trigger marker
|
||||||
(also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that
|
(also a cursor) keeps the solid `line_width_px` stroke. A new measure mark must take that
|
||||||
@@ -57,10 +58,18 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
*finite* sample of the new side and an aggregate cannot say which that is); the caller then opens
|
*finite* sample of the new side and an aggregate cannot say which that is); the caller then opens
|
||||||
that bucket. **Nothing may be folded before a refusal** -- a refused bucket goes to
|
that bucket. **Nothing may be folded before a refusal** -- a refused bucket goes to
|
||||||
`measure_accumulate()`, which adds the same samples again, and sums added above the `return false`
|
`measure_accumulate()`, which adds the same samples again, and sums added above the `return false`
|
||||||
double-counted every opened bucket, reading `top=0.614304` on a unit sine against the 2/pi it must
|
double-counted every opened bucket. That read as `top=0.614304` on a unit sine against the 2/pi a
|
||||||
be. Four things this rests on, each of which breaks it silently:
|
mean over a half then gave; a *bucketed* window no longer reports `top` from those sums at all
|
||||||
|
(see the flat-level invariant below), which makes the rule easier to break silently rather than
|
||||||
|
less binding -- the sums are the fallback a window with no histogram reads, so a wrong one is a
|
||||||
|
wrong answer waiting for that window. Four things this rests on, each of which breaks it silently:
|
||||||
* **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read
|
* **Exactness is the point, so nothing may stride or decimate.** `peak to peak` must still read
|
||||||
exactly `2` on a unit sine and `top` exactly `0.636620` (2/pi). A bucket's sum is a sum.
|
exactly `2` on a unit sine, and it and `average` still come straight out of the one shared
|
||||||
|
`measure_stats()` pass. A bucket's sum is a sum. (`top` has no *exact* reading to protect any
|
||||||
|
more: it is a density estimate and not a mean, and the two places that used to assert
|
||||||
|
`top == 0.636620` on a sine now assert what the flat level actually reads -- see the flat-level
|
||||||
|
invariant below. Everything above is about the *bucket* path being a fast path and not an
|
||||||
|
approximation, and that is unchanged.)
|
||||||
* **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls
|
* **The bucket index space must be the window's.** `ChannelBuffer::acquire()` calls
|
||||||
`pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's
|
`pyramid_.drop_before(values_->base())`, so a channel's pyramid is in the value ring's
|
||||||
absolute index space -- the same space `live_window_locked()` answers in, since a channel's
|
absolute index space -- the same space `live_window_locked()` answers in, since a channel's
|
||||||
@@ -76,9 +85,65 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
the pyramid for nothing.
|
the pyramid for nothing.
|
||||||
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
|
`measure cost:`'s `covered`/`buckets`/`raw` is the guard, and it needs the fast mock to say
|
||||||
anything.
|
anything.
|
||||||
|
- **`Top`/`Bottom`/`Amplitude` read a *flat* level, and the density that finds it is the buckets',
|
||||||
|
not the samples'.** A band-limited edge overshoots and rings -- the mock's `square` at 30
|
||||||
|
harmonics reaches ±1.179 where its plateau is ±1 -- so a measurement that reads the extremes, or
|
||||||
|
a mean over a side of a level, reports the ringing rather than the level the signal dwells at:
|
||||||
|
that is what made this change (`amplitude` read `1.179350` and `top` `0.986496` on the rippled
|
||||||
|
square, against `1.179350` of overshoot where the plateau is `1`). A flat level is a *density*
|
||||||
|
question: the plateau holds most of the samples and the ringing a few percent. So
|
||||||
|
`measure_scan_buckets()` folds a 64-bin histogram of the whole buckets' *midpoints* while it has
|
||||||
|
the list in hand -- one observation per bucket, each bin carrying its buckets' **exact** sum and
|
||||||
|
finite count -- and `measure_flat_levels()` takes, either side of the split, the densest bin that
|
||||||
|
holds at least half the densest one's buckets, **the outermost of those**, and the exact mean of
|
||||||
|
the samples of the buckets within **5 % of the window's peak-to-peak** of it. 5 % is *under* the
|
||||||
|
~9 %-of-swing Gibbs overshoot, so the ringing's excursion falls outside the band while a
|
||||||
|
plateau's own ripple stays inside it; the outermost-of-the-equally-dense rule is what makes a
|
||||||
|
waveform with *no* plateau -- a sine, a ramp -- read its peak instead of an arbitrary mid-range
|
||||||
|
bin that a shifted window would move. `Amplitude` is then `top - bottom`, a bench scope's
|
||||||
|
`Vamplitude`, and **not** the half-swing it used to be, nor the peak-to-peak swing.
|
||||||
|
The readings, fast mock, 2 s window, `[measure-trace]`, harmonics `0` → `30`: `top` `1.000000` →
|
||||||
|
`0.999983`, `bottom` `-1.000000` → `-0.999983`, `amplitude` `2.000000` → `1.999966`, against
|
||||||
|
`peak to peak` `2.000000` → `2.358701` -- the extremes keep the overshoot, and that separation
|
||||||
|
*is* the measurement. On the probe's 1 Hz ±1 sine: `top` `0.957988` (it was `0.636620`, the
|
||||||
|
half-wave mean), `amplitude` `1.915983`, and `peak to peak` still **exactly `2`**; `[stats-probe]`
|
||||||
|
reads `top` `mean=0.957905 sd=0.0014 lo=0.951967 hi=0.962348` over a scrolling window, which is
|
||||||
|
the stability the value band buys -- the answer does not follow the window's alignment.
|
||||||
|
Five things this rests on, each of which breaks something silently:
|
||||||
|
* **Nothing may be read raw for it.** The histogram is the *same* fetched bucket list, walked a
|
||||||
|
second time inside `measure_scan_buckets()`; a histogram of the window's *samples* would be a
|
||||||
|
second O(window) walk, which is exactly what the pyramid path exists to avoid. `measure cost:`
|
||||||
|
is the guard and its `raw` does not move: the probe's pane reads
|
||||||
|
`covered=59684972 buckets=932446 raw=47640 (0.1%)` against the baseline's
|
||||||
|
`covered=61183221 buckets=955856 raw=47402 (0.1%)` -- the same `raw`, 0.44 → 0.50 ms a pass
|
||||||
|
(and `--noise 0.1`, `2.1%` both sides, 0.52 → 0.65 ms).
|
||||||
|
* **It is gated, and the gate is what keeps an unrelated pane free.** `measure_scan_buckets()`'s
|
||||||
|
`flat_hist` comes from `App::MeasureWindow::flat_wanted`, set by the first loop over the pane
|
||||||
|
and only for one of the three types. A pane of means and extremes pays nothing.
|
||||||
|
* **The bin range is the *buckets'*, not the window's.** The window's partial edge buckets and the
|
||||||
|
samples produced since `acquire_all()` are read raw and are not in the list, so
|
||||||
|
`measure_scan_buckets()` tracks its own `blo`/`bhi` over the **non-empty** buckets only -- an
|
||||||
|
all-non-finite bucket is an empty aggregate whose `lo`/`hi` say nothing, and taking them would
|
||||||
|
put a bucket at some arbitrary value. A degenerate range (`bhi == blo`) leaves `folded` at 0,
|
||||||
|
which is the caller's "no histogram at all".
|
||||||
|
* **The *window's* peak-to-peak is what the band is a share of**, and the *split* is sized exactly
|
||||||
|
as `measure_set_level()` sizes a level, so "the top" and "the level a period is counted about"
|
||||||
|
are placed by one rule. The band is a *value* band, which is why the reading does not move when
|
||||||
|
the window does.
|
||||||
|
* **The fallback is the split, and it is what a filtered or sub-bucket window reads.** `hf_fc > 0`
|
||||||
|
disables the pyramid (above) and a window shorter than a bucket has no bucket to fold, so
|
||||||
|
`top`/`bottom` fall back to the mean of each side of the split -- and `amplitude` to the
|
||||||
|
window's own peak to peak, which is the same number on a waveform with nothing to exclude.
|
||||||
|
There is no flat level in that mode to subtract, so `amplitude == top - bottom` is *not* an
|
||||||
|
invariant to lean on there. Measured on the rippled `square` at `hf_fc=100`:
|
||||||
|
`top=0.973824 bottom=-0.991731 amplitude=2.354173 peak to peak=2.354173`, `buckets=0
|
||||||
|
raw=200.0%` -- two raw walks and no density estimate, which is the honest reading of a window
|
||||||
|
the pyramid cannot summarize.
|
||||||
- **`MeasureOptions::noise_pct` is a percentage, clamped in `measure_set_level()` to half the room to the
|
- **`MeasureOptions::noise_pct` is a percentage, clamped in `measure_set_level()` to half the room to the
|
||||||
nearer extreme**, so a band can never invert its own thresholds. It moves the **edge decision only**: the
|
nearer extreme**, so a band can never invert its own thresholds. It moves the **edge decision only**: the
|
||||||
sums still split at the bare level, because `Top` on a unit sine must read 2/pi (0.636620).
|
sums still split at the bare level, which is the *fallback* `Top`/`Bottom` read on a window with no
|
||||||
|
bucket histogram -- a band that moved that split would change what a filtered or sub-bucket window
|
||||||
|
reports, for no gain to a crossing count.
|
||||||
- **`measure_accumulate` loops samples outer, levels inner, and that is not backwards.** The filter holds one
|
- **`measure_accumulate` loops samples outer, levels inner, and that is not backwards.** The filter holds one
|
||||||
state for the whole walk, so levels-outer would step it once per level *per sample* and hand every level but
|
state for the whole walk, so levels-outer would step it once per level *per sample* and hand every level but
|
||||||
the last a different filter state.
|
the last a different filter state.
|
||||||
@@ -105,9 +170,12 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
decides that side and the measurement reads nothing at all, which is worse than reading
|
decides that side and the measurement reads nothing at all, which is worse than reading
|
||||||
something noisy. On a ringing 3.3 V logic signal at a 90 % split: `enters=0` with a fixed band,
|
something noisy. On a ringing 3.3 V logic signal at a 90 % split: `enters=0` with a fixed band,
|
||||||
`enters=4 period=0.200000` with the clamp.
|
`enters=4 period=0.200000` with the clamp.
|
||||||
* **The `sum`/`count` split stays at the bare `level`.** That split *is* `Top`/`Bottom` -- the mean
|
* **The `sum`/`count` split stays at the bare `level`.** Two things read it and neither may be
|
||||||
above and below the threshold -- so a band there changes a documented exact reading. `top` on a
|
moved by a band: a crossing's *side* is decided by the sample's own value against the level
|
||||||
unit sine must still be `0.636620`.
|
(`v == level` is the below side, as it always was), and `Top`/`Bottom` fall back to the mean of
|
||||||
|
each side on a window with no bucket histogram. The band is about *deciding a crossing*, not
|
||||||
|
about where a sample lives, so folding it in would change a filtered window's `top` for no gain
|
||||||
|
to any crossing count.
|
||||||
* **`h = 0` must reduce exactly to the single-level walk**, including the `v == level` skip, so the
|
* **`h = 0` must reduce exactly to the single-level walk**, including the `v == level` skip, so the
|
||||||
band is a parameter of the engine rather than a mode of it.
|
band is a parameter of the engine rather than a mode of it.
|
||||||
The cost is the noise's, not the band's: the clean fast mock reads `raw=119412 (0.1%)` of 215 M at
|
The cost is the noise's, not the band's: the clean fast mock reads `raw=119412 (0.1%)` of 215 M at
|
||||||
|
|||||||
@@ -4,6 +4,25 @@ What a session persists and in what order, when a save is refused, and when an e
|
|||||||
|
|
||||||
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
|
||||||
|
|
||||||
|
- **The trigger is persisted, settings and state both.** Its condition, its channel (by *name*), its
|
||||||
|
pre-trigger share, hold-off and mode, and whether trigger mode was on — written even while it is off,
|
||||||
|
because a trigger configured once and switched off is still the trigger you come back to. The two
|
||||||
|
enumerations are stored as the *words the panel's dropdowns show*, not as their indexes: an index ties
|
||||||
|
the file to a declaration order, which is the lesson `math_op_at()` carries, and the words are shared
|
||||||
|
with the panel so the two cannot drift. The channel resolves when the acquired channels exist
|
||||||
|
(`rebuild_trigger_signals()` picks up `App::pending_trigger_channel`, or `apply_trigger_layout()`
|
||||||
|
resolves it itself when they are already there) — a name that resolved to nothing would silently move
|
||||||
|
the trigger to channel 0.
|
||||||
|
- **A session that loaded is what locks the two panes.** Locked is the returning user's mode: the
|
||||||
|
trigger pane shows one compact line, the signal list only the signals that are on, and neither offers
|
||||||
|
a control that changes the configuration. A *first run* — no session file for that source — never
|
||||||
|
reaches `apply_layout()` and stays in configuration mode, which is what a first run is for. The mode
|
||||||
|
is a mode and not a setting: it is deliberately not persisted, so a scope reopened tomorrow starts
|
||||||
|
where a returning user expects to start. **Dragging a row onto a plot is not configuration and survives
|
||||||
|
the lock**: filling plots is what the list is *for*, so a locked row keeps its `GtkDragSource` — from
|
||||||
|
anywhere on it, the colour chip included, which in configuration mode has a click gesture that claims
|
||||||
|
the sequence precisely so a drag cannot start there. `drag_sources` on the `signals panel:` line is the
|
||||||
|
reading, and it must be `rows - 1`.
|
||||||
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the
|
- **The session is the layout.** `LayoutState` persists what makes a reopened scope look like the
|
||||||
one that was closed: the window, the rows and their slot signals, the calibration, **the enabled
|
one that was closed: the window, the rows and their slot signals, the calibration, **the enabled
|
||||||
signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the
|
signals**, **the chosen trace colours** (`[[color]]`, `name` + `rgb = "#RRGGBB"` — only the
|
||||||
@@ -33,6 +52,40 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
- **The export is collected at click time, not in the file-dialog callback.** The live
|
- **The export is collected at click time, not in the file-dialog callback.** The live
|
||||||
view keeps scrolling while the chooser is up, so a late collection would write a
|
view keeps scrolling while the chooser is up, so a late collection would write a
|
||||||
window the user never saw. The writer is picked by the chosen path's extension.
|
window the user never saw. The writer is picked by the chosen path's extension.
|
||||||
|
- **The MDF export is MDF 3.30 written by hand, and its layout is the reader's, not the
|
||||||
|
standard's prose.** No dependency is added: MF4 is HDF5 and would need a library, while MF3 is a
|
||||||
|
handful of fixed-size blocks, so `write_mdf()` spells them out. The field list comes from
|
||||||
|
`asammdf` 8.8.27 (`blocks/v2_v3_blocks.py`, `blocks/mdf_v3.py`, which parses MF3 by explicit
|
||||||
|
offsets) and was confirmed byte for byte against a known-good 3.30 file asammdf wrote (one
|
||||||
|
group, a `time` master and two `V` channels). The shape, in file order: the **ID block** is 64
|
||||||
|
bytes with *no id/length header* (`"MDF 3.30"`, byte order, version `330`) — a writer that
|
||||||
|
emits `ID` + size + links at offset 0 produces something no reader accepts, since the first real
|
||||||
|
block starts at 64; the **HD block** is 208 (164 common + 44 of the 3.2-era time fields) and
|
||||||
|
points at the data group; a **TX** comment follows it; then the **samples**; the **DG** is 28; a
|
||||||
|
**CC** (conversion, 46 — a `NONE` conversion is the common fields alone) then a **CN** (channel,
|
||||||
|
228) precede each channel; and the **CG** (30) is last. Address fields are absolute file offsets
|
||||||
|
written *forward*, so the whole address map is computed before a byte is written, and every
|
||||||
|
block length is checked after it is built: a field at the wrong width does not fail the write, it
|
||||||
|
leaves the reader in the middle of the next block.
|
||||||
|
- **The data group's data address holds the raw records, with no `DT` header** — which is what
|
||||||
|
asammdf's *writer* does and what its *reader* assumes (`DataBlock` is documented as "a pseudo
|
||||||
|
block not defined by the MDF 3 standard"). This is the one place the file follows the reader
|
||||||
|
rather than a narrow reading of the standard, and it is the assumption ScryLab compatibility
|
||||||
|
rests on: a file with a `DT` block there would have its header read as sample bytes.
|
||||||
|
- **In MDF the master is `abs_seconds`, named `time`, and the relative axis is a channel
|
||||||
|
`time_relative`** — the same two numbers the `.npy`/`.csv` table holds, in the other roles. A
|
||||||
|
master off the relative column is 0 at a different sample in each display mode, so it would put a
|
||||||
|
live export and a held event's export on two timelines, which is the one thing the second column
|
||||||
|
exists to avoid; both carry the unit `s`, and the signal columns follow in the `.npy`/`.csv`
|
||||||
|
order. **The unit** is the client's CONFIG unit (a math channel's own), and it is written into
|
||||||
|
the CC block because that is the only place MDF 3 keeps one — and the place ScryLab reads a
|
||||||
|
signal's unit from. `ExportTable::Column` gained the field for it; the two older writers ignore
|
||||||
|
it, and their bytes are unchanged. **The names** are sanitised and de-duplicated against each
|
||||||
|
other *and* against the two reserved names before being cut to 31 bytes (the CN name field is 32
|
||||||
|
with its NUL): ScryLab imports only the **first** occurrence of a channel name, so a cut made
|
||||||
|
before de-duplication could silently drop a signal. `write_export()` is the one place the
|
||||||
|
extension picks the writer (`.csv`, `.mf3`/`.mdf`, anything else `.npy`), and both the dialog and
|
||||||
|
the self-check's export probe come through it so the two cannot offer different formats.
|
||||||
- **The chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries
|
- **The chooser runs before `ScopeModel` exists, and `--server` is what decides.** `Config` carries
|
||||||
`server_specified`, set only by the `--server` branch, and `main()` asks only when it is false —
|
`server_specified`, set only by the `--server` branch, and `main()` asks only when it is false —
|
||||||
so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and
|
so declining costs no buffer and no socket, and `ScopeModel`, `Acquisition`, `RowSet` and
|
||||||
|
|||||||
@@ -18,6 +18,23 @@ Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to lear
|
|||||||
reports it as `zoom ... span=.. window=.. limited=1`.
|
reports it as `zoom ... span=.. window=.. limited=1`.
|
||||||
Its pan clamping is likewise gated on the data being wider than the view, or the empty
|
Its pan clamping is likewise gated on the data being wider than the view, or the empty
|
||||||
left-hand side drags the live edge off the right-hand edge.
|
left-hand side drags the live edge off the right-hand edge.
|
||||||
|
- **The compact pane's arm control is built in the heading, and *one* function states both arm
|
||||||
|
buttons.** Arming is a runtime decision and the locked pane is exactly where it is made, so the
|
||||||
|
control cannot live in the body — that is the part which goes away. `sync_arm_buttons()` is what
|
||||||
|
states the label, the sensitivity and the visibility of both buttons, and it is called from the four
|
||||||
|
places any of its three inputs can change (the click, the toolbar toggle, the curator's re-read,
|
||||||
|
`apply_pane_lock()`). Visibility is `enabled && locked`: with trigger mode off the line beside it
|
||||||
|
already says "stream (trigger off)" and there is no engine to arm, and with the pane open the body's
|
||||||
|
button is the one on screen — **two arm buttons at once would be two controls for one state**, which
|
||||||
|
is why the probe reads the body's allocation as `-1` while the compact one is up.
|
||||||
|
- **A widget's allocation is not readable in the tick it is first shown.** The compact arm control is
|
||||||
|
created hidden and shown for the first time inside a signal handler, so `gtk_widget_get_width()` in
|
||||||
|
that same tick reads `0` while `gtk_widget_is_visible()` already says `1` — and its sibling's stale
|
||||||
|
allocation (270 px, from when the *body* was still on screen) reads as a live one, because
|
||||||
|
`gtk_widget_get_visible()` is the widget's *own* flag and stays true under a hidden parent. The width
|
||||||
|
only becomes the proof that a control is on the surface **one frame later**, which is why the probe
|
||||||
|
has a `compact_arm_step()` of its own; and the control to compare against must be measured with
|
||||||
|
`is_visible()` or not at all.
|
||||||
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
|
- **Signals start disabled** — enabling is what starts acquisition. A trigger on a
|
||||||
signal nobody acquires can never fire, so selecting/arming enables it.
|
signal nobody acquires can never fire, so selecting/arming enables it.
|
||||||
- **The trigger's event window is separate from the display window**, and
|
- **The trigger's event window is separate from the display window**, and
|
||||||
@@ -224,13 +241,18 @@ is the complaint this answers.
|
|||||||
- `rebuild_trigger_signals()`, finding a `channel` of `-1` after the rebuild renumbered the
|
- `rebuild_trigger_signals()`, finding a `channel` of `-1` after the rebuild renumbered the
|
||||||
channels, silently settles on the first signal *and* re-defaults the level -- which is why
|
channels, silently settles on the first signal *and* re-defaults the level -- which is why
|
||||||
`trigger_level_touched` is restored with the configuration.
|
`trigger_level_touched` is restored with the configuration.
|
||||||
- **It waits for the stream's own channel list, gated on `generation()` having moved.** The restored
|
- **It waits for the trigger's channel *name* to resolve again, not for `generation()` to move.** The
|
||||||
name has to be resolved against the live channels, and those do not exist until the first CONFIG of
|
restored name has to be resolved against the live channels, and a file view that renamed its channels
|
||||||
the restarted client lands on the producer thread. `App::live_restore_generation` is the generation
|
(`square (A)`) must be gone first or the name selects the wrong signal. `App::live_restore_pending`
|
||||||
read when *Go live* was pressed; `live_restore_pending` stays set until it differs **and**
|
stays set until `channels()` is non-empty **and** a channel carries the snapshot's name (or the
|
||||||
`channels()` is non-empty. A `pending=1` that never clears is therefore a reading about the
|
snapshot named none). Gating that on the generation was wrong in the common case: a *single* event
|
||||||
*stream*, not about the restore -- see `invariants-session-and-export.md` for the save that made it
|
keeps the stream's own channel names, and the restarted client's CONFIG is identical, which the model
|
||||||
stick at `channels=0`.
|
treats as **no change on purpose** (`same_stream_signals()`) -- so the generation never moved and the
|
||||||
|
restore waited for something that could not happen. What shook it loose was toggling the trigger,
|
||||||
|
which *is* a structural change: "click live, then toggle the trigger a couple of times" was the
|
||||||
|
symptom. A `pending=1` that never clears is a reading about the *stream* (no channels yet), not about
|
||||||
|
the restore -- see `invariants-session-and-export.md` for the save that made it stick at
|
||||||
|
`channels=0`.
|
||||||
- **Only the two controls a file view actually moved are restated** -- the signal dropdown and the
|
- **Only the two controls a file view actually moved are restated** -- the signal dropdown and the
|
||||||
level spin. The rest of the trigger panel was never touched, and writing to a control fires its
|
level spin. The rest of the trigger panel was never touched, and writing to a control fires its
|
||||||
handler, so the ones with nothing to put back are left alone. Both writes are inside
|
handler, so the ones with nothing to put back are left alone. Both writes are inside
|
||||||
|
|||||||
+419
-28
@@ -126,10 +126,27 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
An export taken **mid-capture** reports `mode=live` with `time=[-pre..e]` where `e` is how far
|
An export taken **mid-capture** reports `mode=live` with `time=[-pre..e]` where `e` is how far
|
||||||
the post window has filled — the export is what is on screen, and during a capture that is a
|
the post window has filled — the export is what is on screen, and during a capture that is a
|
||||||
partly filled window. Pin the probe outside a capture when checking the `rows=` count.
|
partly filled window. Pin the probe outside a capture when checking the `rows=` count.
|
||||||
The two `bytes=` lines are the writers: the `.npy` is
|
The three `bytes=` lines are the writers: the `.npy` is
|
||||||
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
|
`64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)`, so a size that is not that is a
|
||||||
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
|
mispacked record. Read it back with `pd.DataFrame(np.load(path))` after changing
|
||||||
either writer.
|
either writer.
|
||||||
|
The `.mf3` line's size is the MDF 3.30 layout the writer spells out, and every term of it is
|
||||||
|
observable: `272 + comment + rows·record + 28 + channels·274 + 30`, where the ID block is 64,
|
||||||
|
the HD block 208, the comment is a `TX` block of its text plus 5 bytes, `record` is
|
||||||
|
`16 + 4·cols` (two `f64` time columns then one `f32` per signal), the data group is 28, each of
|
||||||
|
`channels = cols + 2` gets a 46-byte conversion block and a 228-byte channel block, and the
|
||||||
|
channel group is 30. Read the file back with asammdf: `MDF(path).info()` must show one group
|
||||||
|
whose `channel 0` is `name="time" type=master`, and `get(name, group=0).unit` must be the unit
|
||||||
|
the next line reports.
|
||||||
|
`[export-probe] mdf master=time master_unit=s channels=N record=B units=['s' 's' 'V' …]`
|
||||||
|
is the other half, and it is the assertion on the units: `channels` and `record` are the two
|
||||||
|
numbers the `.mf3` size above is built from (so `record` must be `16 + 4·cols` and `channels`
|
||||||
|
`cols + 2`), and `units` must be **the CONFIG's**, in the table's column order, behind the
|
||||||
|
master's and the relative axis's two `s` — one `'s' 's'` pair plus one entry per exported
|
||||||
|
column, each exactly the unit the stream sent (an empty one reads `''`). A `V` where the mock
|
||||||
|
sent `s`, or a missing entry, is the unit not reaching the conversion block, which is the only
|
||||||
|
thing ScryLab reads a signal's unit from; `write_npy`/`write_csv` must be unchanged either way
|
||||||
|
(their bytes are pinned by the two lines above).
|
||||||
- `[window-probe] <action> label=.. entry=.. unit=U window=W event_span=E cap=C decim=M
|
- `[window-probe] <action> label=.. entry=.. unit=U window=W event_span=E cap=C decim=M
|
||||||
stored=R aa=F buffered=B` (stderr, `UDPSCOPE_PROBE_WINDOW_MS`) — **`window` must equal
|
stored=R aa=F buffered=B` (stderr, `UDPSCOPE_PROBE_WINDOW_MS`) — **`window` must equal
|
||||||
`event_span`** on every line (the trigger's event span is the display window; a capture
|
`event_span`** on every line (the trigger's event span is the display window; a capture
|
||||||
@@ -201,19 +218,32 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
pane_h=H switch_on=X reset_sensitive=Y`
|
pane_h=H switch_on=X reset_sensitive=Y`
|
||||||
(stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first
|
(stderr, `UDPSCOPE_PROBE_MEASURES_MS`) — adds one measurement of every type on the first
|
||||||
plottable signal and lets the tick compute them, so what it prints is what a user reads.
|
plottable signal and lets the tick compute them, so what it prints is what a user reads.
|
||||||
On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.64 and
|
On the test stream's 1 Hz ±1 sine the values must be: average ≈ 0, `top` ≈ 0.96 and
|
||||||
`bottom` ≈ -0.64 (the mean of each half of a sine), max/min ≈ ±1, `pulse duration` ≈ 0.5 s
|
`bottom` ≈ -0.96, `amplitude` ≈ 1.92, max/min ≈ ±1, `peak to peak` **exactly 2**,
|
||||||
with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. It needs **`UDPSCOPE_ENABLE_ALL_MS`
|
`pulse duration` ≈ 0.5 s
|
||||||
ahead of it** or it prints `no acquired signal to measure` and the dump reads `n=0`.
|
with `duty` ≈ 0.5, `period` ≈ 1 s and `frequency` ≈ 1 Hz. **`top` is no longer 2/π**, and that
|
||||||
|
is the point of it: it is the *flat* level of the upper side, read from the density of the
|
||||||
|
window rather than from a mean over a side, and a sine has no plateau — so the densest band
|
||||||
|
sits at its peak and the reading is near it, not the 0.636620 a mean over a half would give.
|
||||||
|
`amplitude` is that pair's delta (`top - bottom`), which is why it reads about 1.92 rather than
|
||||||
|
the half-swing it used to: a bench scope's `Vamplitude` is `Vtop - Vbase`. The measured
|
||||||
|
figures, and the reason they are what they are, are in
|
||||||
|
[invariants-measurements.md](invariants-measurements.md) — on the mock's `square` the
|
||||||
|
separation is the visible one (`top`/`bottom` `0.999983`/`-0.999983`, `amplitude` `1.999966`,
|
||||||
|
while `peak to peak` keeps the edges' ±1.179 overshoot at `2.358701`). It needs
|
||||||
|
**`UDPSCOPE_ENABLE_ALL_MS` ahead of it** or it prints `no acquired signal to measure` and the
|
||||||
|
dump reads `n=0`.
|
||||||
**Run it against `--noise 0.1 --freq 1` as well, and the three crossing types must still read
|
**Run it against `--noise 0.1 --freq 1` as well, and the three crossing types must still read
|
||||||
those figures** (measured: `period=1.000100 frequency=0.999900 pulse duration=0.504100`): the
|
those figures** (measured: `period=1.000100 frequency=0.999900 pulse duration=0.504100`): the
|
||||||
hysteresis band is the only thing holding them there, a single level reads a 500 Hz interferer as
|
hysteresis band is the only thing holding them there, a single level reads a 500 Hz interferer as
|
||||||
the signal, and the default `--noise 0` mock cannot see it. The band costs `raw`, not accuracy --
|
the signal, and the default `--noise 0` mock cannot see it. The band costs `raw`, not accuracy --
|
||||||
the clean fast mock reads `raw=0.1%` at 1.41 ms a recompute and the noisy one `raw=2.0%` at 1.96
|
the clean fast mock reads `raw=0.1%` at 1.41 ms a recompute and the noisy one `raw=2.0%` at 1.96
|
||||||
ms, so read `measure cost:` on **both**. `marks=` lists each mark with its kind, and the **mix is the point**:
|
ms, so read `measure cost:` on **both**. `marks=` lists each mark with its kind, and the **mix is the point**:
|
||||||
`level:V` alone for average/top/bottom, `level:V,time:T` for min/max (a ruler at the value
|
`level:V` alone for average/top/bottom; **two** level marks for `peak to peak` (the window's
|
||||||
|
extremes) and for `amplitude` (the two *flat* levels it subtracts -- a pair of rulers, because
|
||||||
|
the span between them *is* the measurement); `level:V,time:T` for min/max (a ruler at the value
|
||||||
*and* a marker at the first sample that reached it — two level marks at both extremes was
|
*and* a marker at the first sample that reached it — two level marks at both extremes was
|
||||||
the bug), `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with
|
the bug); `time:A,time:B` for pulse/period/frequency. `text=` is the pane's own string with
|
||||||
its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration`
|
its unit, so `frequency` must read `Hz` (not the signal's unit), `period`/`pulse duration`
|
||||||
seconds, and the rest the channel's unit. `switch_on` and `reset_sensitive` are the panel's *frame* read back: the switch widget against
|
seconds, and the rest the channel's unit. `switch_on` and `reset_sensitive` are the panel's *frame* read back: the switch widget against
|
||||||
`measure_stats_on`, which the code drives, and the Reset button that follows it. Either disagreeing with
|
`measure_stats_on`, which the code drives, and the Reset button that follows it. Either disagreeing with
|
||||||
@@ -378,6 +408,64 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
on the first tick for the same reason the toolbar's are. **Both must read `(ok)`**: the page titles are
|
on the first tick for the same reason the toolbar's are. **Both must read `(ok)`**: the page titles are
|
||||||
empty on purpose, so an icon that does not resolve leaves a *blank* pill, and a blank pill looks
|
empty on purpose, so an icon that does not resolve leaves a *blank* pill, and a blank pill looks
|
||||||
exactly like a page with nothing in it.
|
exactly like a page with nothing in it.
|
||||||
|
- `help layer: contextual=on hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} icon=dialog-information-symbolic(ok) hint=12x12; tour=idle steps=5 step=0 auto=1 requested=0` (in the exit
|
||||||
|
dump, beside `switcher:`) — the help affordance layer as the last tick found it. **`hints=` must equal
|
||||||
|
the number of ids**, and the ids are the surfaces the contextual help is on: eleven of the window
|
||||||
|
itself, and `math` only while the math editor is open (its heading lives in a builder that is rebuilt
|
||||||
|
on every opening, so it is not one of the window's own). Four of the eleven are not a pane's *heading*
|
||||||
|
but the row of a specific control — `events_record` (the "record events" switch in the trigger
|
||||||
|
panel), `trigger_advanced` (the pre-trigger share row), `trigger_search` (the *header* of the search
|
||||||
|
block's expander, which is what lets it be on screen while the block is collapsed) and
|
||||||
|
`signals_config` (the signals pane's hint line, whose box exists so a hint can join it) — which is
|
||||||
|
the rule the layer follows: a hint is a sibling of the thing it explains and never an overlay on
|
||||||
|
it. The layer is **off by default**
|
||||||
|
(`contextual_help` in `settings.toml` is false for a missing file), so a fresh-config run reads
|
||||||
|
`contextual=off hints=0` and that line above is the state *after* the Help row was switched on;
|
||||||
|
`contextual=off` beside a non-zero count is the toggle not having removed its buttons — **off is
|
||||||
|
*gone*, not greyed**. **`icon=` must read `(ok)`**: an "(i)" whose name Adwaita lacks is a
|
||||||
|
*blank* control that still works, the failure the pane [edit] buttons already paid for once. The name
|
||||||
|
is `dialog-information-symbolic`, the plain information mark; `help-about-symbolic` is the status
|
||||||
|
bar's ⓘ and a second meaning for it is what the app's own icons exist to avoid. `hint=WxH` is the
|
||||||
|
size the **first visible** hint was allocated, measured the way every other rectangle here is
|
||||||
|
(`gtk_widget_compute_bounds()`), and **12x12 is the reading a "much tinier" mark must keep**: the
|
||||||
|
class zeroes the outer `menubutton` node and its `button` subnode (a `GtkMenuButton`'s measure
|
||||||
|
ignores both `min-width` on the widget and a size request), and the glyph is a third node *below*
|
||||||
|
the subnode — `menubutton > button > box > image`, read off the CSS node dump. The direct-grandchild
|
||||||
|
form `> button > image` matches **nothing** and silently leaves the glyph at its natural 16 px,
|
||||||
|
which is the 16x16 an earlier version shipped by mistake; and there is no lower floor to find
|
||||||
|
(the symbolic glyph renders at any request, measured down to 4 px), so 12 px is a legibility
|
||||||
|
choice. The default 34x34 is the number to compare against. A hint inside a collapsed expander's
|
||||||
|
*child* is skipped, so the number is never a hidden widget's 0x0 — but a hint on the expander's
|
||||||
|
*header* is in the widget tree and reads its real size while the block is collapsed (see the
|
||||||
|
`collapsed search:` entry below). `tour=` is `running`/`idle` with the plan's `steps=` and the
|
||||||
|
current `step=`; `auto=1` is the first-run auto-start having fired *in this process* — the flag is
|
||||||
|
`tour_seen` in `settings.toml`, so a second run against the same config directory reads `auto=0` —
|
||||||
|
and `requested=1` is the **chooser's** tour having handed over (`Config::tour_pending`), which is
|
||||||
|
`auto=0` because the request came from the other window rather than from this process's first run.
|
||||||
|
- `gtk warnings: N` (in the exit dump, beside `help layer:`) — **GTK's own log at warning or above,
|
||||||
|
counted for the whole run, and it must read `0`.** It is counted through a log *writer* function
|
||||||
|
because GTK is built with structured logging: its messages go through the writer and never through
|
||||||
|
`g_log_set_handler()`/`g_log_set_default_handler()`, which is exactly why no probe can read one —
|
||||||
|
the message is written by the toolkit from inside a call of ours and is stored in no widget. The
|
||||||
|
first class to need it was `Trying to measure GtkBox … for width of N, but it needs at least M`
|
||||||
|
(once per frame, from a card measured for a height with a width a margin short of its own minimum;
|
||||||
|
see the allocation bullet in
|
||||||
|
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)). The writer chains to
|
||||||
|
`g_log_writer_default()`, so every message still reaches stderr exactly as before and the documented
|
||||||
|
baselines are read the same way.
|
||||||
|
- `help figures: trigger_head=X,Y,W,H trigger_summary=… trigger_panel=… signal_list=… side_switcher=… side_stack=… window_button=… status_label=… header_bar=… plot=…` (in the exit
|
||||||
|
dump, beside `help layer:`) — the **window-coordinate** rectangle of each surface the help's annotated
|
||||||
|
figures label, `x,y,width,height`, or `-1,-1,0,0` for a surface that is **not on screen**. It is
|
||||||
|
measured on the tick while the window is alive (nothing in the dump may call `gtk_widget_*` — see
|
||||||
|
[verification.md](verification.md)) and with `gtk_widget_is_visible()`, not `get_visible()`: a widget
|
||||||
|
under a hidden parent — `trigger_summary` while the pane is in *configuration* mode, the Measure page
|
||||||
|
while the Signals tab is showing — keeps its own flag true and reads `0x0`, and a crop box drawn from
|
||||||
|
that is a box over the wrong thing. The figures are produced by cropping a real screenshot, so these
|
||||||
|
numbers **are** the crop boxes; their values depend on the window's size, which under the documented
|
||||||
|
`xvfb-run` is the window's 756x894 minimum (the 640x480 screen is smaller than the content), so a run
|
||||||
|
on a larger screen reads different numbers for the same panels. `trigger_summary` reading
|
||||||
|
`-1,-1,0,0` is the *configuration* pane rather than a defect: it is the locked pane's content, and
|
||||||
|
`UDPSCOPE_PROBE_HELPUI_MS` locks the pane for that leg.
|
||||||
- `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump,
|
- `app icons: org.udpscope.app(ok 128x128) org.udpscope.app-symbolic(ok 16x16)` (in the exit dump,
|
||||||
beside `toolbar icons:`) — the application icon, named and present at the size its own root
|
beside `toolbar icons:`) — the application icon, named and present at the size its own root
|
||||||
`<svg>` element declares. **Both entries must read `ok` at those sizes.** `ok` is
|
`<svg>` element declares. **Both entries must read `ok` at those sizes.** `ok` is
|
||||||
@@ -416,6 +504,61 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a
|
connect_sensitive=1` then a `picked` line, with no SIGSEGV, which is as much a crash test of a
|
||||||
dialog built and destroyed as it is a state check. An empty list must print `picked nothing:
|
dialog built and destroyed as it is a state check. An empty list must print `picked nothing:
|
||||||
the list is empty` rather than dereferencing a null row.
|
the list is empty` rather than dereferencing a null row.
|
||||||
|
|
||||||
|
**The command that reaches the chooser at all is `UDPSCOPE_PROBE_PICK_MS`, and this is worth
|
||||||
|
saying because three earlier attempts found no chooser and no output:** with no `--server` and no
|
||||||
|
probe, the chooser runs its own nested `GMainLoop` and *blocks* — there is nothing to time it out
|
||||||
|
and no dump runs, so a bare `./build/udpscope` (bare or under a pty) sits there silently. The
|
||||||
|
working form, against the mock:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=6000 UDPSCOPE_PROBE_PICK_MS=900 \
|
||||||
|
./build/udpscope
|
||||||
|
```
|
||||||
|
|
||||||
|
(with `XDG_CONFIG_HOME` pointing at a directory holding at least one saved source, or the pick
|
||||||
|
finds an empty list and the run prints `picked nothing`). It is the same command the docs give
|
||||||
|
elsewhere with `--server` removed and `UDPSCOPE_PROBE_PICK_MS` added.
|
||||||
|
- `[sources] chooser ui: … hints=[N {ids}] hint=WxH` (stderr, `UDPSCOPE_PROBE_PICK_MS`) — the
|
||||||
|
chooser's frame *and* its help layer in one line. `hints=[2 {chooser_sources chooser_waveform}]`
|
||||||
|
is the layer **on** (`contextual_help` in the file the run reads) and `hints=[0 {}] hint=0x0` is
|
||||||
|
it **off**, which is the same "off is gone" rule the window's line reads, in the window that runs
|
||||||
|
before `App` exists. The two ids are the surfaces this dialog has a row for — the heading above
|
||||||
|
the saved-source list and the waveforms page's action row; the form's `AdwEntryRow`s are *not*
|
||||||
|
hosts (a row is not a box and its editor is internal, so a hint cannot join it as a sibling) and
|
||||||
|
the connection fields are explained by their group's `description` instead. `hint=WxH` is the
|
||||||
|
same 12x12 the window's line reads: the button is `ui_impl::context_help_button()`, the one
|
||||||
|
`udpscope-hint` affordance both windows draw.
|
||||||
|
- `[sources] tour: first run -- auto_started=1 in the chooser` then
|
||||||
|
`[sources] chooser tour: visible=1 steps=5 step=0` (stderr) — **a first run's tour starts in the
|
||||||
|
chooser**, on the chooser's own widgets, from the same `settings.tour_seen` the window's
|
||||||
|
auto-start reads and with the same 1.5 s delay. `steps=5` is the chooser's plan (the source list,
|
||||||
|
the two tabs, New host, the help menu and the preferences). Driven by the walk itself:
|
||||||
|
|
||||||
|
- `UDPSCOPE_PROBE_CHOOSER_TOUR=1` emits `::clicked` on the help menu's **Guided tour** row, which
|
||||||
|
now starts *this* walk rather than only requesting the window's: `[sources] chooser help: tour
|
||||||
|
started in the chooser`, then `[sources] chooser tour: visible=1 steps=5 step=0`, then
|
||||||
|
`[sources] chooser tour: finished 5 stops -- the window continues it (tour_pending=1)` and
|
||||||
|
`[sources] chooser tour: after finish visible=0 tour_pending=1`. **`tour_pending=1` and
|
||||||
|
`visible=0` is the handoff assertion** — the walk is over here and the request rides on the one
|
||||||
|
`Config` value `main()` hands to both windows. The walk is driven in one tick (it is the handoff
|
||||||
|
being asserted, not the geometry), so the pages it selects are not readable here; the timed
|
||||||
|
`_MS` form below walks a stop per tick and reads them.
|
||||||
|
- `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=<ms>` walks the tour a stop per **tick** (250 ms), the cadence
|
||||||
|
the window's own probe uses and for its reason: a page a stop selects is allocated on the frame
|
||||||
|
after the switch. It starts the walk itself when one is not already running, so it does not
|
||||||
|
depend on the config directory being a first run. Every tick prints the page the stack shows and
|
||||||
|
the page the stop *stands on*:
|
||||||
|
`[sources] chooser tour: step=N/5 page=<shown> stop_page=<declared> target_mapped=D`. **The
|
||||||
|
"Two kinds of input" stop stands on `waveforms`**: `step=2/5 page=waveforms stop_page=waveforms`
|
||||||
|
is the switch. Then it steps **Back** off that stop and reads the page again —
|
||||||
|
`[sources] chooser tour: back off the waveforms stop: step=1/5 page=sources` — which is the other
|
||||||
|
direction, and the previous stop stands on `sources`. It then walks to the end:
|
||||||
|
`[sources] chooser tour: finished 5 stops -- the window continues it (tour_pending=1)` and
|
||||||
|
`after finish visible=0 page=sources tour_pending=1`. Paired with `UDPSCOPE_PROBE_PICK_MS` after
|
||||||
|
the walk, the run shows the other half: the application's `help layer:` reads `tour=running …
|
||||||
|
auto=0 requested=1`, the window's tour having been started by `tour_start_requested()` rather
|
||||||
|
than by the first-run timer.
|
||||||
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
|
- **The session layout round trip** (`~/.config/udpscope/<host>_<port>.toml`) — run once
|
||||||
with a drop and a window change, then again with no interaction: the second run's dump
|
with a drop and a window change, then again with no interaction: the second run's dump
|
||||||
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
|
must read `layout loaded from ...` and `layout: rows=R window_s=W row0=[...]` with the
|
||||||
@@ -593,7 +736,7 @@ Two lines of the self-check output are assertions in disguise:
|
|||||||
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
|
so the panes the tick rewrites are measured directly. The `[width-probe]` lines are the
|
||||||
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
|
per-change detail behind the `axes.w changes` count, and a step over 100 px dumps the widget
|
||||||
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
|
tree's wide subtrees — that is what the documented, intermittent ~320 px step wants read.
|
||||||
- `[help] pages=14 parsed=14 shortest='Pause and play' 1627 B; shortcuts: app=12 plot=14 tables=26 ui=26 ui_accel=19 ui_icon=7 ui_bare=0; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — **`parsed` must equal `pages`** and no page may be empty: a missing resource or markup Pango rejects renders a one-line stand-in instead of failing, so the window *looks* fine and only this count notices. The `shortcuts:` numbers are the shortcut overview four ways.
|
- `[help] topics=14 index=1 figures=3 dir=/…/help/C/org.udpscope.app; shortcuts: app=14 plot=14 tables=28 ui=28 ui_accel=21 ui_icon=7 ui_bare=0; about version=… commit=… built=… settings=… icon=org.udpscope.app; closed ok` (stderr, `UDPSCOPE_PROBE_HELP_MS`) — the manual is a Mallard set on disk now, not a GResource in the binary, so this is a **file-system reading**: `topics` is the `.page` files other than `index.page`, `index` must be 1, and `figures` counts the screenshots under `figures/`. **`topics=0 index=0` means the manual is missing** from `dir` (the source directory the build was configured from, `UDPSCOPE_HELP_DIR`) — the finding the old `parsed != pages` used to name. The manual itself opens in Yelp, another process, which the probe now launches and reports (the `[help] launch` line below). The `shortcuts:` numbers are the shortcut overview four ways.
|
||||||
`app` and `plot` are the rows in the C tables -- what the application actually *handles* -- and `tables`
|
`app` and `plot` are the rows in the C tables -- what the application actually *handles* -- and `tables`
|
||||||
is their sum. `ui`, `ui_accel`, `ui_icon` and `ui_bare` are what `src/ui/shortcuts.ui` *declares*, read
|
is their sum. `ui`, `ui_accel`, `ui_icon` and `ui_bare` are what `src/ui/shortcuts.ui` *declares*, read
|
||||||
out of the resource with a markup parse rather than off the built widgets, because a tree walk counts
|
out of the resource with a markup parse rather than off the built widgets, because a tree walk counts
|
||||||
@@ -601,7 +744,8 @@ every row twice: `GtkShortcutsWindow` reaches each one from more than one place
|
|||||||
**`ui` must equal `tables`** -- two lists of one thing, and a row in one but not the other is a gesture
|
**`ui` must equal `tables`** -- two lists of one thing, and a row in one but not the other is a gesture
|
||||||
that works and is not documented, or is documented and does nothing -- and **`ui_bare` must be zero**: a
|
that works and is not documented, or is documented and does nothing -- and **`ui_bare` must be zero**: a
|
||||||
row with no accelerator and no icon is a cell reading "No Shortcut". `ui_accel + ui_icon == ui`, so those
|
row with no accelerator and no icon is a cell reading "No Shortcut". `ui_accel + ui_icon == ui`, so those
|
||||||
two also catch a row whose accelerator went missing. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of three windows built, presented and closed in one run as it is a state check, and a stronger one than it was: the three are now closed by **two different calls**, so a mismatched pair crashes or leaks here. One of the three is a `GtkWindow` (the help window, `gtk_window_close()`); the About dialog and the shortcut overview are `AdwDialog`s and close with `adw_dialog_close()`, cleared on `::closed`. `shortest=` is the page title — a page whose body was never compiled into the binary is absent rather than empty, and a title alone is the stand-in's shape.
|
two also catch a row whose accelerator went missing. `settings=` must be a real path (the same one the About dialog shows). **`icon=` must read `org.udpscope.app`** — it is read back through `adw_about_dialog_get_application_icon()` while the dialog is open, so the dialog is asserted to *carry* the icon rather than merely to open (a `set_application_icon()` lost in an edit leaves a dialog that opens, looks almost right, and shows a generic placeholder). `closed ok` with **no `Gtk-CRITICAL`** is as much a crash test of the two dialogs built, presented and closed in one run as it is a state check: they are closed by **two different calls**, so a mismatched pair crashes or leaks here. One is a `GtkWindow` (the shortcut overview, `gtk_window_close()`/`::close-request`); the About dialog is an `AdwDialog` and closes with `adw_dialog_close()`, cleared on `::closed`. The manual is *not* one of them — it opens in Yelp, a separate process, so there is no window of ours to read back or close; the line below is the reading of that path instead.
|
||||||
|
- `[help] launch attempted=1 done=1 ok=1 error='' installed=/home/…/.local/share/help/C/org.udpscope.app` (stderr, `UDPSCOPE_PROBE_HELP_MS`, a moment after the line above) — the **launch of the manual**, the one part of the help path no other reading reaches: a successful launch leaves no window of ours, so nothing else can say whether `show_help()` did anything at all. `attempted=` is set at the call (`0` would be a probe that never got as far as calling it); `done=` says the asynchronous callback ran, and **`done=0` is the reading for a launch that never called back**; `ok=1` is the `help:` URI handed to a viewer, `ok=0` with the launcher's `error=` text is a launch that failed. **`ok` is not "a window appeared"**: Yelp is another process, and under a headless X server it cannot map one — the launch is *this* process's half (the URI reached a registered handler), which is exactly what the desktop does when a user presses F1, and what an absent handler or an uninstalled viewer breaks. `installed=` is `help_installed_path()`'s answer — the `$XDG_DATA_HOME`/`$XDG_DATA_DIRS` directory the document was found in, or `(not found)` — and it is what a failing `ok=0` should be read beside, because the launch error alone cannot tell a missing document from a missing handler. On a machine with the set installed by `cmake --install build --prefix ~/.local` and `yelp` present, the line reads `ok=1 … installed=~/.local/share/help/C/org.udpscope.app`. A **failure** leaves the launcher's `GtkAlertDialog` up on purpose — that is what a user gets, and the probe must not report a failed path as a silence.
|
||||||
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
|
- `[menu] N 'label' accel='key' h=P label_x=X accel_right=R/W order=D ok=O`, one line per row,
|
||||||
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
|
then `[menu] rows=R expected=E order=D rows_bad=B popover_visible=V mapped=M button_mapped=B`
|
||||||
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
|
and `[menu] status pos=top bubble=WxH button_top=T button_right=R fits=F` (stderr,
|
||||||
@@ -621,7 +765,101 @@ two also catch a row whose accelerator went missing. `settings=` must be a real
|
|||||||
every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses
|
every row then reports `h=0`. It turns autohide off for its own measurement — a popover dismisses
|
||||||
itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores
|
itself when it loses the grab, and `xvfb-run` has no window manager to hand it one — and restores
|
||||||
it afterwards.
|
it afterwards.
|
||||||
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: tab signals->measure | g: tab measure->signals | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**. The `m`/`g` pairs are the *tab name* before and after, which is what says those two keys select a tab rather than toggling a pane; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
|
- `[helpui] auto_started=1 visible=1 steps=5 step=0 (first run)` (stderr, `UDPSCOPE_PROBE_HELPUI_MS`) —
|
||||||
|
the help affordance layer end to end, in ten steps 300-400 ms apart (every reading is an
|
||||||
|
*allocation*, and a step that read its own predecessor's geometry would read a rectangle from before
|
||||||
|
the frame that placed it). The line above is the **first-run auto-start**, read before the probe
|
||||||
|
touches anything: `auto_started=1 visible=1` is the tour that started itself, and the parenthetical is
|
||||||
|
`settings.tour_seen` as the process found it — `tour_seen already set` with `auto=0` is the *second*
|
||||||
|
run against the same config directory, which is the assertion that the flag persisted. Then:
|
||||||
|
- `[helpui] contextual on: hints=12 ids={events plot status cursors measures trigger trigger_advanced events_record trigger_search signals signals_config window} hint=12x12` —
|
||||||
|
the twelve "(i)" affordances, by id, and the size the first visible one was allocated. Eleven are
|
||||||
|
the window's own surfaces (four of them a specific control's row rather than a heading — see the
|
||||||
|
`help layer:` entry) and the twelfth is `math`, which is present only while the math editor
|
||||||
|
window is open; it reads 11 with the editor shut. **On a fresh config this reads `hints=0 ids={}`
|
||||||
|
and `hint=0x0`**, because the layer is off by default; the example is the state after the row below
|
||||||
|
was switched on, and a fresh run's `hints=0` beside a `settings.toml` with no `contextual_help`
|
||||||
|
key is the default being read rather than assumed. **`hint=12x12` is the "much tinier" assertion**
|
||||||
|
— the default menu-button mark is 34x34 (see `help layer:`), and anything near that is the class
|
||||||
|
not reaching the glyph's own node.
|
||||||
|
- `[helpui] main view: rows=7 help_row=1` and one `[helpui] entry N '<label>' accel='<key>' order=O`
|
||||||
|
per row of the **Help view** — `entry 0 'Back'` with no accelerator and then the four the submenu
|
||||||
|
promises: `entry 1 'Keyboard shortcuts' accel='Ctrl+/'`, `entry 2 'Documentation' accel='F1'`,
|
||||||
|
`entry 3 'Guided tour' accel='Ctrl+T'`, `entry 4 'Enable contextual help' accel=''`. **`order=1` on
|
||||||
|
every row** is the assertion; the view is entered by emitting `::clicked` on the menu's own `Help`
|
||||||
|
row, so the row's handler is exercised and not just the stack.
|
||||||
|
- `[helpui] help view: rows=5 expected=5 rows_bad=0 back=1 toggle=1 entries='…'` — the summary, with
|
||||||
|
`toggle=` being the contextual-help row's check, which *is* `settings.contextual_help`. It reads `0`
|
||||||
|
on a fresh config (the default) and `1` on one where the setting is on; the probe's next step drives
|
||||||
|
it both ways, which is why the example here is the `1` side.
|
||||||
|
- `[helpui] toggle: off -> hints=0 ids={}; on -> hints=12 ids={…}; setting=on` — the toggle's effect
|
||||||
|
read both ways through the row itself. **`hints=0` with `off` is the assertion**: off means the
|
||||||
|
affordances are *gone*, not greyed, so anything else is a row that hid its buttons instead of
|
||||||
|
removing them. The count comes back to the same twelve, which is the other half — a toggle that
|
||||||
|
removed and could not restore is a one-way switch.
|
||||||
|
- `[helpui] tour started from the probe: visible=1 steps=5 step=0` — the tour started the way
|
||||||
|
`Ctrl+T` starts it. `steps=5` is the plan: the table's five steps whose surface is on screen or
|
||||||
|
selectable into it (`gtk_widget_get_mapped()` -- a wrong *page* is kept, because selecting its
|
||||||
|
page reveals it, while a *collapsed pane* is still dropped; see the surface bullets in
|
||||||
|
[invariants-gtk-and-chrome.md](invariants-gtk-and-chrome.md)).
|
||||||
|
- `[helpui] step=1/5 visible=1 side_tab=signals spot=[x,y WxH] target=[…] contains=1 card=[…] clear=C`
|
||||||
|
— **in window coordinates**, the frame a screenshot is cropped in, with the sidebar's page beside
|
||||||
|
it (a stop the walk had to change the page for is readable as such; see `[toursurface]` below).
|
||||||
|
**`contains` must be 1** on every step: the spotlight is the target's own rectangle grown three
|
||||||
|
pixels. `clear` is the card missing the target, and it is 1 wherever the window has room for the
|
||||||
|
card beside or above/below the hole — at the documented run's window (see `help figures:` below)
|
||||||
|
the plot's step is *the whole content*, because at 756 px the split view overlays the side panel
|
||||||
|
rather than putting it beside the plot, so `clear=0` there is the geometry and not a placement
|
||||||
|
bug. The side panel's step reads `clear=1`.
|
||||||
|
- `[helpui] after Next: step=2/5 visible=1`, `[helpui] after Back: step=1/5 visible=1`,
|
||||||
|
`[helpui] after Skip: visible=0 dims_hidden=1 spot_hidden=1 card_hidden=1 controllers=12 (was 12)
|
||||||
|
click_gestures=2` — the three controls. **`controllers=` equal to `(was N)` is the "no lingering
|
||||||
|
input grab" reading**: a tour of ordinary overlay widgets takes no grab at all, and a count that
|
||||||
|
grew would be one somebody added.
|
||||||
|
- `[helpui] layer: …` — `capture_help_state()`'s line at the end of the run, the same text the exit
|
||||||
|
dump prints as `help layer:`.
|
||||||
|
- `[helpui] figures locked: …` and `[helpui] figures config: …` — `help figures:` read in both of the
|
||||||
|
trigger pane's modes, by clicking its [edit] button. This exists because one of the help's figures
|
||||||
|
is the *locked* pane's summary grid (`trigger_summary`), which in configuration mode is not on
|
||||||
|
screen at all and reads `-1,-1,0,0`; the two lines give the geometry agent both states' numbers
|
||||||
|
rather than leaving them to find the mode. `trigger_panel`'s height differs between them (508 px in
|
||||||
|
configuration mode against 72 locked — the body is replaced by the summary), which is the check
|
||||||
|
that the mode really moved.
|
||||||
|
- `[helpui] collapsed search: expanded=0 mapped=1 mapped_header=1 hint=12x12` (the last step, in
|
||||||
|
configuration mode, after the figures line) — the search block's "(i)" read on the expander's
|
||||||
|
**header** with the block **collapsed**. **`expanded=0 mapped=1` is the assertion**: a hint on the
|
||||||
|
expander's own *child* is not in the widget-parent chain while the block is collapsed and would
|
||||||
|
read `mapped=0` (and `0x0`), which is exactly why the header box exists (`search_head` in
|
||||||
|
`src/ui/trigger-panel.ui`) and why the hint joins it as its last child. `mapped_header=` is the
|
||||||
|
header box beside it, and `hint=WxH` is the same 12x12 the layer's own line reads.
|
||||||
|
- `[toursurface] …` (stderr, `UDPSCOPE_PROBE_TOUR_SURFACE_MS`) — a stop that stands on a page it is
|
||||||
|
not on, entered from the page it is not on. It puts the sidebar on **Measure**, starts the window's
|
||||||
|
tour, and walks it a stop per tick (400 ms), because a stack allocates the new page's widgets on
|
||||||
|
the frame *after* the switch — so each reading is of the page the step left standing, not of the
|
||||||
|
one the switch has just left behind. The lines:
|
||||||
|
|
||||||
|
- `[toursurface] before: side_tab=signals signal_list_mapped=1 running=1` — the state a first run
|
||||||
|
starts in (a fresh config's sidebar is on Signals), then the auto-started walk is dismissed.
|
||||||
|
- `[toursurface] on measure: side_tab=measure signal_list_mapped=0 side_stack_mapped=1` — a frame
|
||||||
|
after the switch: **`signal_list_mapped=0` beside `side_stack_mapped=1`** is the whole premise,
|
||||||
|
the list is genuinely off screen while the stack that holds it is on screen.
|
||||||
|
- `[toursurface] tour: visible=1 steps=5 step=0 signals_stop=1` — **`signals_stop=1` is the filter's
|
||||||
|
reading**: a stop whose target is *not mapped* is still in the plan because it names a page the
|
||||||
|
stack can select. `steps=5` is the full table.
|
||||||
|
- `[toursurface] step=N/5 side_tab=<page> target_mapped=D[ target=signal_list]` — one per stop. The
|
||||||
|
page moves from `measure` to `signals` at the `trigger` stop (the first stop whose target is on
|
||||||
|
the Signals page) and stays there; **the `signals` stop reads `target_mapped=1`**.
|
||||||
|
- `[toursurface] signals stop=4/5 visible=1 side_tab=signals spot=[…] target=[…] contains=1
|
||||||
|
card=[…] clear=1` — the `tour_geometry_line()` the helpui probe uses, read on the frame *after*
|
||||||
|
the switch: **`side_tab=signals` with `contains=1` is the assertion** that the spotlight and the
|
||||||
|
card are on the list, not on the page the walk came from.
|
||||||
|
- `[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0 side_stop=0` — the other half
|
||||||
|
of the filter, read: with `sidebar_wanted` off (`update_side_visibility()` re-asserts it, so this
|
||||||
|
is how a probe puts the sidebar away for good) the split view unmaps the column, the stack with
|
||||||
|
it, and the two stops that need the sidebar are dropped while the page-less ones stay. This is
|
||||||
|
what says a *wrong page* and a *collapsed pane* are not the same case.
|
||||||
|
- `[keys] a: armed 0->1 | space: paused 0->1 | t: trigger panel: 0->1 | c: cursors: 0->1 | m: tab signals->measure | g: tab measure->signals | … | editable focused: 'm' refused=1` (stderr, `UDPSCOPE_PROBE_KEYS_MS`) — **every `X->Y` pair must differ** and **`refused` must be 1**.
|
||||||
|
The `m`/`g` pairs are the *tab name* before and after, which is what says those two keys select a tab rather than toggling a pane; `t`/`c` read the toolbar toggle's own state, because those two enable a feature and the button is where that state is visible. The refusal is the whole reason the controller is in the bubble phase: every bare-letter accel is also a character, so an accel that fires while a `GtkEditable` has focus makes the window entry unusable. A pair reading `0->0` is an action whose dispatch does not reach the handler a click reaches. Note the format is `<key>: <widget-description>: <before>-><after>` — the name passed to `before_after()` includes the widget's description, so `t: trigger panel: 0->1` is the correct form (not `t: trigger panel 0->1` without the colon before the values).
|
||||||
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
|
- `[color] '<name>' at row R slot S: trace=#RRGGBB chip_agrees=1 (want 1)`, then `[color] override: trace=#123456 chip_agrees=1`, `[color] session: entries=1 rgb=#123456` and `[color] default: trace=#RRGGBB chip_agrees=1` (stderr, `UDPSCOPE_PROBE_COLOR_MS`) — **all three `chip_agrees` must be 1**, the override line's `trace` must be `#123456`, the session line must read `entries=1 rgb=#123456`, and the default line's `trace` must be back to the first line's. Run it with `UDPSCOPE_ENABLE_ALL_MS` ahead of it, or there is no bound trace and the probe prints `no bound trace to recolour` instead of asserting anything; on the standard mock the first line reads `'sine' at row 0 slot 0: trace=#4FA3FF`. Two things make this an assertion rather than a tautology. The colours are read out of **`plot::Plot::trace().color`** — what the renderer will stroke, reached through `Row::plot` *after* `relayout_areas()`, since a new colour is a new figure — and out of the chip's own `<span foreground="…">` markup, not by asking `RowSet::signal_color()` twice, which would pass against a chip that never reached the trace. And the override is `#123456`, deliberately in no palette slot: an override equal to the entry it replaces passes against an implementation that ignores overrides entirely. The session leg round-trips `collect_layout()` → `save_layout()` → `load_layout()` on a temp path and deletes it, because a probe must not leave a colour in the real session file for a later run to inherit.
|
||||||
- `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N
|
- `[reseq-probe] reseq=N late=N stale=N overflows=N holes=N hole_slots=N backfilled=N
|
||||||
open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` (stderr,
|
open_holes=N bands=N bands_repaired=N nan_slots=N scanned=N trigger_in_hole=D` (stderr,
|
||||||
@@ -775,18 +1013,26 @@ pane's recompute count since start) and one `| n=… mean=… sd=… lo=… hi=
|
|||||||
measurement. Phase 1 adds `dn=` per group.
|
measurement. Phase 1 adds `dn=` per group.
|
||||||
|
|
||||||
Measured readings (fast mock, 1 Hz sine, with
|
Measured readings (fast mock, 1 Hz sine, with
|
||||||
`UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_MEASURE_MS=1500 UDPSCOPE_PROBE_STATS_MS=2500`):
|
`UDPSCOPE_ENABLE_ALL_MS=800 UDPSCOPE_PROBE_MEASURE_MS=1500 UDPSCOPE_PROBE_STATS_MS=2500`, the
|
||||||
|
four measurements the measure probe adds -- average, top, peak to peak, amplitude):
|
||||||
|
|
||||||
```
|
```
|
||||||
phase=0 on=1 passes=36 | n=30 mean=-0.0370313 sd=0.0953509 lo=-0.246367 hi=0.105167 | n=30 mean=0.633511 … | n=30 mean=1.99567 … hi=2 | n=30 mean=0.997834 … hi=1
|
phase=0 on=1 passes=43 | n=28 mean=0.0826556 sd=0.085427 lo=-0.0314722 hi=0.208469 | n=28 mean=0.957905 sd=0.00142833 lo=0.951967 hi=0.962348 | n=28 mean=2 sd=0 lo=2 hi=2 | n=28 mean=1.91652 sd=0.00300419 lo=1.90993 hi=1.92432
|
||||||
phase=1 … dn=0 (on all four measurements)
|
phase=1 … dn=1 (on all four measurements)
|
||||||
phase=2 … n=1 mean=-4.84467e-14 | n=1 mean=0.63662 | n=1 mean=2 | n=1 mean=1
|
phase=2 … n=0 mean=0 sd=0 lo=0 hi=0 (all four)
|
||||||
```
|
```
|
||||||
|
|
||||||
- `phase=0` — the running count. **`n` must be in the low tens**, not thousands: a count in the
|
- `phase=0` — the running count. **`n` must be in the low tens**, not thousands: a count in the
|
||||||
thousands means the accumulator is fed per sample, not per recompute. `mean=0.633511` (≈ 2/π)
|
thousands means the accumulator is fed per sample, not per recompute. `mean=2 sd=0 lo=2 hi=2` for
|
||||||
for the `top` measurement on a unit sine is the check that the threshold types are correct, not
|
the `peak to peak` measurement on a unit sine is the assertion on the shared pass: the extremes of
|
||||||
merely plausible. `sd` must be 0 exactly when `n < 2`, and `lo`/`hi` must bracket `mean`.
|
a sine are exactly ±1 whatever the window does, so anything else is the scan, not the reading.
|
||||||
|
The two flat-level types are the other half and their figure is the **`sd`**, not the mean: `top`
|
||||||
|
reads `mean=0.957905 sd=0.00142833` and `amplitude` `mean=1.91652 sd=0.0030` over windows that
|
||||||
|
scroll under them, and a `top` that wanders by tenths is a level that follows the window's
|
||||||
|
alignment instead of a *value* band (see the flat-level invariant in
|
||||||
|
[invariants-measurements.md](invariants-measurements.md); the mean itself is window-dependent on a
|
||||||
|
sine, which has no plateau -- the densest band sits at its peak and moves with how much of the
|
||||||
|
peak a window holds). `sd` must be 0 exactly when `n < 2`, and `lo`/`hi` must bracket `mean`.
|
||||||
- `phase=1` — after a two-second pause. **`dn` must be 0 or 1**: a pinned window recomputes once
|
- `phase=1` — after a two-second pause. **`dn` must be 0 or 1**: a pinned window recomputes once
|
||||||
when it settles and then never again. A `dn` near 120 is the second defect — accumulating per
|
when it settles and then never again. A `dn` near 120 is the second defect — accumulating per
|
||||||
frame, which adds a hundred-odd observations of the identical window and collapses `sd` towards
|
frame, which adds a hundred-odd observations of the identical window and collapses `sd` towards
|
||||||
@@ -802,6 +1048,12 @@ must **not** also be set (two arms on the same port race). Run with
|
|||||||
`--rate 2000 --batch 64 --freq 5 --glitch-at 0.25 --glitch-width 0.004` and
|
`--rate 2000 --batch 64 --freq 5 --glitch-at 0.25 --glitch-width 0.004` and
|
||||||
`UDPSCOPE_EXIT_AFTER_MS=12000`.
|
`UDPSCOPE_EXIT_AFTER_MS=12000`.
|
||||||
|
|
||||||
|
**That recipe needs no `--square-harmonics`**: the mock's `square` rings by default (see
|
||||||
|
[build-and-run.md](build-and-run.md)), and the odd-harmonic series passes through zero at the ideal edge's
|
||||||
|
own crossings, so the glitch's inverted 0.004 s pulse, the hit count, `agree` and the pulse-width leg's
|
||||||
|
`measured=0.200000` all read what they read on the step. Only a *level* the ripple reaches and the step does
|
||||||
|
not — the extremes, `y = ±1.179` — would need `--square-harmonics 0`, and no probe here asserts one.
|
||||||
|
|
||||||
Measured output (port 8419):
|
Measured output (port 8419):
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -999,7 +1251,10 @@ configuration that carries settings, is the defect this exists for — a saved h
|
|||||||
## `[sources] waveforms` and the waveform load
|
## `[sources] waveforms` and the waveform load
|
||||||
|
|
||||||
```
|
```
|
||||||
[sources] chooser ui: page='sources' title='Sources' icon=yes page='waveforms' title='Waveforms' icon=yes visible='sources' switcher_wired=1 buttons=[prefs=1 new_host=1 open=1 cancel=1]
|
[sources] chooser ui: page='sources' title='Sources' icon=yes page='waveforms' title='Waveforms' icon=yes visible='sources' switcher_wired=1 buttons=[prefs=1 new_host=1 open=1 cancel=1] help=[button=1 docs=1 tour=1 toggle=1 on=0]
|
||||||
|
[sources] chooser help: tour requested
|
||||||
|
[sources] chooser help: tour_pending=1
|
||||||
|
[sources] chooser help: toggle=1 setting=1 file=/tmp/…/udpscope/settings.toml
|
||||||
[sources] waveforms: 1 recent /tmp/rt.npy(ok) page=waveforms
|
[sources] waveforms: 1 recent /tmp/rt.npy(ok) page=waveforms
|
||||||
[sources] picked waveform /tmp/rt.npy
|
[sources] picked waveform /tmp/rt.npy
|
||||||
[udpscope] waveform loaded: /tmp/rt.npy signals=4 samples=20001 rows=20001 decim=1 span=2.000000s rate=10000 time=[-2.000000..0.000000]
|
[udpscope] waveform loaded: /tmp/rt.npy signals=4 samples=20001 rows=20001 decim=1 span=2.000000s rate=10000 time=[-2.000000..0.000000]
|
||||||
@@ -1008,7 +1263,7 @@ configuration that carries settings, is the defect this exists for — a saved h
|
|||||||
|
|
||||||
| field | value it must read | the defect a wrong reading names |
|
| field | value it must read | the defect a wrong reading names |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `chooser ui:` | both pages with a `title` and an `icon=yes`, `switcher_wired=1`, and all four buttons `1` | the chooser's `.ui` losing an id: the code names the missing one and refuses the window, and this line is the frame that *did* build -- a page with `icon=NO` is one the switcher cannot draw |
|
| `chooser ui:` | both pages with a `title` and an `icon=yes`, `switcher_wired=1`, all four buttons `1`, and `help=[button=1 docs=1 tour=1 toggle=1 on=0]` | the chooser's `.ui` losing an id: the code names the missing one and refuses the window, and this line is the frame that *did* build -- a page with `icon=NO` is one the switcher cannot draw, and a `help=` field at `0` is a help row (or the button that opens it) the dialog no longer carries |
|
||||||
| `waveforms:` | one entry per remembered export, with `(ok)` or `(missing)`, and `page=waveforms` once the page is shown | the Waveforms page not built, or a stale path offered as if it were openable |
|
| `waveforms:` | one entry per remembered export, with `(ok)` or `(missing)`, and `page=waveforms` once the page is shown | the Waveforms page not built, or a stale path offered as if it were openable |
|
||||||
| `picked waveform` | the selected path | a pick that carries no file: `--open` is what the mode reads |
|
| `picked waveform` | the selected path | a pick that carries no file: `--open` is what the mode reads |
|
||||||
| `signals=`, `rows=` | the export's `cols=` and `rows=` | a reader mis-slicing its records, or the columns bound to the wrong channels |
|
| `signals=`, `rows=` | the export's `cols=` and `rows=` | a reader mis-slicing its records, or the columns bound to the wrong channels |
|
||||||
@@ -1017,6 +1272,21 @@ configuration that carries settings, is the defect this exists for — a saved h
|
|||||||
| `span=`, `rate=`, `time=[…]` | the exported window and rate, `[-window, 0]` | the plan not covering the whole file (the tail arrives as the whole picture) or the display epoch not pinned to the file's last stamp |
|
| `span=`, `rate=`, `time=[…]` | the exported window and rate, `[-window, 0]` | the plan not covering the whole file (the tail arrives as the whole picture) or the display epoch not pinned to the file's last stamp |
|
||||||
| `frames=0 packets=0` beside `channels=N` | 0 — no acquisition | a socket opened for a file, which would also make the starvation notice live |
|
| `frames=0 packets=0` beside `channels=N` | 0 — no acquisition | a socket opened for a file, which would also make the starvation notice live |
|
||||||
|
|
||||||
|
The chooser's **help menu** — `Documentation`, `Guided tour` and `Enable contextual help`, the three
|
||||||
|
affordances the window's Help view carries, moved in front of a user who has not started yet — has two
|
||||||
|
probe legs, both of which then fall through to the normal first-row pick so the run they affect is the
|
||||||
|
run they report:
|
||||||
|
|
||||||
|
- `UDPSCOPE_PROBE_CHOOSER_TOUR=1` emits `::clicked` on `Guided tour`, and `[sources] chooser help:
|
||||||
|
tour_pending=1` reads the request back out of the `Config` the run will be handed. **`tour_pending=1`
|
||||||
|
is the assertion**: the tour is drawn on a window that does not exist yet, so the entry can only work
|
||||||
|
by setting that field, and the reading it *started* is the exit dump's `help layer:` — `tour=running
|
||||||
|
… auto=0 requested=1`, `requested=1` being this path (see `help layer:` above).
|
||||||
|
- `UDPSCOPE_PROBE_CHOOSER_HELP=1` sets the `Enable contextual help` row active and reads the settings
|
||||||
|
file back: `toggle=1 setting=1 file=<settings.toml>`. **`setting=1` beside `toggle=1`** is the pair
|
||||||
|
that matters — the row *is* the setting, and the app is built from the file and not from the dialog,
|
||||||
|
so the file is where it has to have landed.
|
||||||
|
|
||||||
## `[drop-probe]`
|
## `[drop-probe]`
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -1079,11 +1349,22 @@ the event is opened precisely so the pair can disagree — with the event's own
|
|||||||
nothing to tell "restored" from "untouched".
|
nothing to tell "restored" from "untouched".
|
||||||
|
|
||||||
Both lines are read a *later* step, not in the handler that pressed *Live*: the restore waits for the
|
Both lines are read a *later* step, not in the handler that pressed *Live*: the restore waits for the
|
||||||
restarted client's first CONFIG, so it is performed by a tick ~900 ms on. `pending=0` is what says it
|
restarted client's channel list being usable again — the restored trigger's channel *name* has to
|
||||||
happened; `gen=a/b` is the generation now against the one *Go live* read. A **stuck `pending=1`** is
|
resolve — so it is performed by a tick ~900 ms on. `pending=0` is what says it happened; `gen=a/b` is the
|
||||||
not a reading about the restore but about the stream behind it — the generation never moved, or the
|
generation now against the one *Go live* read, and it is informational: the restore no longer waits for it
|
||||||
channel list came back empty, which is how a session file poisoned by a save taken during a group
|
to move, because a restarted client whose CONFIG is identical is deliberately *no change*, so a single
|
||||||
produced `pending=1 channels=0` and a live view with nothing on it.
|
event — which keeps the stream's own channel names — never moved it and the restore waited until the
|
||||||
|
trigger was toggled. A **stuck `pending=1`** is not a reading about the restore but about the stream
|
||||||
|
behind it: the channel list came back empty, which is how a session file poisoned by a save taken during a
|
||||||
|
group produced `pending=1 channels=0` and a live view with nothing on it.
|
||||||
|
|
||||||
|
**Run this one against a *configured* live view.** The four paired fields are the probe's own capture of
|
||||||
|
what the live view held when the file view opened over it, so a bare first run legitimately reads
|
||||||
|
`window=2.000000 (want 0.000000) trigger=1 (want 0) channel='' (want '')` — the wants are the empty
|
||||||
|
snapshot and the values are the application's defaults, which is not a defect. The documented line above
|
||||||
|
is reproduced from a session that has a window and a trigger (`window_s = 0.5`, `trigger_enabled = true`,
|
||||||
|
`trigger_signal = "square"`); the probe sets its own window to 0.5 s before opening the event, and a
|
||||||
|
session that then disagrees with it takes the pair apart.
|
||||||
|
|
||||||
| field | value it must read | the defect a wrong reading names |
|
| field | value it must read | the defect a wrong reading names |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -1139,6 +1420,71 @@ library measures and draws the rect in tests of its own.
|
|||||||
defect this reading exists for, and it was invisible from the frame alone: the file view pinning its
|
defect this reading exists for, and it was invisible from the frame alone: the file view pinning its
|
||||||
axis from the ring each frame is what swallowed the gesture.
|
axis from the ring each frame is what swallowed the gesture.
|
||||||
|
|
||||||
|
## `panes:` — the locked and configuration modes
|
||||||
|
|
||||||
|
```
|
||||||
|
[udpscope] pane drag: sidebar hidden for the drag=1 restored after it=1
|
||||||
|
[udpscope] pane edits: locked=document-edit-symbolic(ok) config=object-select-symbolic(ok)
|
||||||
|
[udpscope] panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, falling | level: 0.3 .. 0.6 | mode: single | noise band: 0.2 | pre-trigger: 20 % | hold-off: 1000 ms'
|
||||||
|
[udpscope] panes: trigger=config signals=config body_visible=1 summary=''
|
||||||
|
[udpscope] panes: the same line as the probe found it: 'mode: stream (trigger off)'
|
||||||
|
```
|
||||||
|
|
||||||
|
**`pane drag:`** is a drag begun through the *source* with the split view forced into its **overlaid**
|
||||||
|
state, which is what a window with no room beside the plot produces. Both must read `1`: with the sidebar
|
||||||
|
covering the plot there is no drop target under the pointer, so a drag out of the signal list is
|
||||||
|
*impossible* rather than merely awkward, and `row_drag_sidebar()` steps the sidebar aside for the length of
|
||||||
|
the drag and puts it back after. `hidden=0` is an overlaid drag that can never land anywhere.
|
||||||
|
|
||||||
|
**`pane edits:`** is the two `[edit]` buttons in each mode, by name and verdict, and it exists because of
|
||||||
|
a real defect: the "done" icon was `emblem-ok-symbolic`, which **Adwaita does not have** (breeze does), so
|
||||||
|
the button that leaves configuration mode was blank — and a blank icon button is invisible in every other
|
||||||
|
reading, exactly as `toolbar icons:` describes for the toolbar. The probe clicks into configuration mode
|
||||||
|
and back, so one line carries both names. **Both must read `(ok)`**, and the pair is
|
||||||
|
`document-edit-symbolic` (the pencil: edit) and `object-select-symbolic` (the check: done).
|
||||||
|
|
||||||
|
Both panes have two modes. **Locked** is what a returning user gets — the trigger pane shows the one line
|
||||||
|
in `summary`, the signal list shows only the signals that are on, and neither offers a control that
|
||||||
|
changes the configuration — and **config** is the full pane, which is what a *first run* gets, because a
|
||||||
|
session file that loaded is what "we have been here before" means. Each heading's `[edit]` button flips
|
||||||
|
its pane.
|
||||||
|
|
||||||
|
- **With trigger mode off the summary is one line**: `mode: stream (trigger off)`. The four lines below
|
||||||
|
describe a trigger that is not running, so quoting them would describe nothing. The dump carries the line
|
||||||
|
twice — once as the probe found it and once at the end — because only a run whose *session* has the
|
||||||
|
trigger off shows the stream form, and a run whose session has it on shows the settings. **The pair is
|
||||||
|
also the refresh test**: the probe toggles the trigger, so a run whose session has it off must show
|
||||||
|
`stream (trigger off)` in the line it found and the settings in the live one. Both showing `stream` is a
|
||||||
|
compact view that did not refresh when the mode changed, which is a defect this pair caught.
|
||||||
|
- **`trigger panel sensitive …=1` is now a guard, not a state.** The trigger's body is never disabled —
|
||||||
|
a trigger is configured before it is switched on — so this reads `1` always, and a `0` means the
|
||||||
|
settings have been made unreachable again. The *cursors'* body is disabled with the cursors, and that
|
||||||
|
is the pane whose readout has nothing to show.
|
||||||
|
- **`compact arm with the trigger off: visible=0` / `with the trigger on and the pane locked:
|
||||||
|
visible=1 sensitive=1 width=51 label=Disarm`** is the compact pane's arm control, in the two states
|
||||||
|
that define it. The first says it is *not* on the line when there is no engine to arm; the second says
|
||||||
|
it is, and that it is live (`sensitive=1`) and laid out (`width` > 0 — a width can only be read the
|
||||||
|
frame *after* it is first shown, see the invariants). `label` must agree with `model->trigger_armed()`
|
||||||
|
rather than with a fixed word, because a single-shot latch may have disarmed it by the time the probe
|
||||||
|
looks. `[udpscope] compact arm probe: locked=1 enabled=1 own=1 effective=1 width=51 panel_width=-1
|
||||||
|
label=Disarm` prints the inputs beside the outcome, and **`panel_width=-1` is the assertion that the
|
||||||
|
body's own Arm button is not on screen at the same time** — two arm controls for one state is the
|
||||||
|
defect it exists to catch.
|
||||||
|
- **`arm sensitive with the trigger off=0`** is the one exception inside that body: `Arm` is a *runtime*
|
||||||
|
control, so with trigger mode off there is no engine to arm and it is disabled (and labelled `Arm`
|
||||||
|
again). `1` here is an Arm button that does nothing when clicked.
|
||||||
|
- **`summary`** is the locked trigger pane's whole content, as `key: value` pairs separated by ` |`. The
|
||||||
|
pane draws it as a small definition list — `signal`, `condition`, `level`, `mode` on four lines, and
|
||||||
|
then only the figures that are *not* at their defaults (`noise band`, `HF reject`, `pre-trigger`,
|
||||||
|
`hold-off`), because a line reading "band 0" is a line that says nothing. One line was tried first and
|
||||||
|
was too little: the four answers are what a user opens the pane for. `summary=''` with
|
||||||
|
`body_visible=0` is a locked pane showing nothing at all, which is the defect this reads for.
|
||||||
|
- **`body_visible=0`** is the settings being off screen, which is what makes the pane a couple of hundred
|
||||||
|
pixels instead of 580 in `panels:`.
|
||||||
|
- **The locked signal list drops the rows of the signals that are off**, so `rows` counts what is on:
|
||||||
|
`signals panel: … rows=3 signals=2 divider=1` is two on signals and the `MATH` divider, not four
|
||||||
|
signals. Its `+` goes with them — a `+` on a locked list opens the editor the lock exists to keep shut.
|
||||||
|
|
||||||
## `panel switches:` and the trigger panel's default sensitivity
|
## `panel switches:` and the trigger panel's default sensitivity
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -1306,7 +1652,11 @@ through one row type, for the reason in `invariants-gtk-and-chrome.md`. The form
|
|||||||
[udpscope] trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4
|
[udpscope] trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4
|
||||||
```
|
```
|
||||||
|
|
||||||
The trigger panel's frame, read from the window: `boxes` is the row boxes the `.ui` holds, `controls_inside`
|
The trigger panel's frame, read from the window: **the `.ui`'s own panel** (`trigger_body`), not the box
|
||||||
|
this app wraps it in — the heading row and the locked summary are the code's arrangement, and reading the
|
||||||
|
*wrapper* made this line report `boxes=2 … search_inside=0/4` the moment the heading moved out of the file.
|
||||||
|
That is the failure this line is for, so it is worth knowing it can happen to itself. `boxes` is the row
|
||||||
|
boxes the `.ui` holds, `controls_inside`
|
||||||
counts the fifteen controls the code built and then appended into those rows -- the last two being the
|
counts the fifteen controls the code built and then appended into those rows -- the last two being the
|
||||||
noise band and the HF reject, whose rows arrived with the noise-rejection work, `search_widgets` is the four
|
noise band and the HF reject, whose rows arrived with the noise-rejection work, `search_widgets` is the four
|
||||||
search buttons having resolved by id, and `search_inside` is those four being inside the block the expander
|
search buttons having resolved by id, and `search_inside` is those four being inside the block the expander
|
||||||
@@ -1329,8 +1679,13 @@ the two lines together read what the file gave and what the code put in it.
|
|||||||
The signals panel's frame read from the window, and in the same line the *shape* of the one channel list:
|
The signals panel's frame read from the window, and in the same line the *shape* of the one channel list:
|
||||||
the heading and the hint the `.ui` gives it, the list the rows go in, how many rows that list holds
|
the heading and the hint the `.ui` gives it, the list the rows go in, how many rows that list holds
|
||||||
(`rows`), how many of them are streamed signals (`signals`), how many math channels the app has (`math`),
|
(`rows`), how many of them are streamed signals (`signals`), how many math channels the app has (`math`),
|
||||||
and `divider`, which is 1 when `rows == signals + 1 + math` and 0 otherwise. `labels=1` is a `.ui` that lost
|
and `divider`, which is 1 when `rows == signals + 1 + math` and 0 otherwise. **`drag_sources` must be
|
||||||
one of the two labels, `list=0` one that lost the list -- both of which otherwise show as a panel quietly
|
`rows - 1`** — one per signal row, the `MATH` divider not being draggable. Dragging a signal onto a plot is
|
||||||
|
how a plot is filled, so it has to survive both pane modes: the locked list drops the tick, the menu and the
|
||||||
|
`+`, and this is the reading that says the drag did not go with them. (`0` is a list that has become a thing
|
||||||
|
you can read but not use.) `labels=1` is a `.ui` that lost
|
||||||
|
one of the two labels — counted over the panel's subtree *above* the list, because the rows' own name
|
||||||
|
and unit labels are the code's and the heading sits in a heading row beside the pane's `[edit]` button — `list=0` one that lost the list -- both of which otherwise show as a panel quietly
|
||||||
missing a piece -- and **`divider=0` is the merged list that lost its `+`**, the one control that can create
|
missing a piece -- and **`divider=0` is the merged list that lost its `+`**, the one control that can create
|
||||||
a math channel. The first line above is a run with no math channels at all: the divider is there anyway,
|
a math channel. The first line above is a run with no math channels at all: the divider is there anyway,
|
||||||
because a `+` that only appeared once a channel existed could never create the first one. `panels: …
|
because a `+` that only appeared once a channel existed could never create the first one. `panels: …
|
||||||
@@ -1409,6 +1764,35 @@ which must be untouched:
|
|||||||
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) math_off=[math x2,math3] math_on=[math lp] notice=''sine' is off: 1 trace left the plots; math off: math x2 and 1 more'
|
[enable-all-probe] switched 'sine' off: traces_left=0 (want 0) math_off=[math x2,math3] math_on=[math lp] notice=''sine' is off: 1 trace left the plots; math off: math x2 and 1 more'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## `trig engine:` — the producer's per-second engine line
|
||||||
|
|
||||||
|
```
|
||||||
|
[udpscope] trig engine: ch=1 armed=1 pending=0 elapsed=0.0000/0.1000 s accum=0 history=0.0000/0.4000 s pre_pct=80 v=1 now_t=2.520 holdoff_until=2.520 ring=[2.5200..2.5200] n=567000
|
||||||
|
```
|
||||||
|
|
||||||
|
Printed by the producer once a second while a trigger is configured, so a run with no trigger at all shows
|
||||||
|
none. `elapsed/post_want` is the capture's progress in stream seconds, `accum` how many samples that window
|
||||||
|
holds, `history/pre` how much pre-trigger history the engine has, and `now_t` the stamp of the last sample
|
||||||
|
the **engine** was fed (`engine.last_t()`), not the stream's time.
|
||||||
|
|
||||||
|
**`ring=[oldest..newest] n=` is the store's own retained span and sample count, and it is the pair that says
|
||||||
|
which of two failures a stalled display is.** `n` counts every stored sample, so it climbs at the stream's
|
||||||
|
rate whenever data arrives at all:
|
||||||
|
|
||||||
|
- **`ring` standing still while `n` climbs** is the *clock*: samples are stored but every one carries the
|
||||||
|
same stamp, so the axis cannot advance and the trigger's `history` never fills. The stamps are pinned at
|
||||||
|
the file view's last value because a restarted client's stream clock sits behind it: the advance test
|
||||||
|
rejects the new packets, four rejections abandon the declared time signal, and the arrival-clock fallback
|
||||||
|
derives its epoch from the surviving mark. `Acquisition::start()` now resets the store and re-bases that
|
||||||
|
mark, so `ring` must **move** within a stream round-trip of *Go live*. Measured after going live
|
||||||
|
from an event view: `ring=[2.5200..2.5200] n=567000 → n=1131000 → n=1689000`, one second apart, with
|
||||||
|
`now_t` and `holdoff_until` both pinned at `2.520` — the file view's last stamp. The producer's clock was
|
||||||
|
frozen, not lagging, and the display stayed blank until something re-planned the store.
|
||||||
|
- **`ring` advancing while `now_t` does not** is the *feed*: the store is moving and the engine is not being
|
||||||
|
handed the samples, which is a trigger-path question and not a clock one.
|
||||||
|
|
||||||
|
Neither can be read off the engine's own fields, which is why the store's pair is here.
|
||||||
|
|
||||||
## `[udpscope] buffer allocated:`
|
## `[udpscope] buffer allocated:`
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -1497,7 +1881,14 @@ nothing itself but *is* rebuilt under when a graph is installed at its instant -
|
|||||||
- **`trigger delay:`** -- the reject's group delay, measured on **`square`**, which carries no impulses: a
|
- **`trigger delay:`** -- the reject's group delay, measured on **`square`**, which carries no impulses: a
|
||||||
one-pole turns a -1 -> +1 step into a zero crossing at exactly `tau*ln2`, so `delay` must read
|
one-pole turns a -1 -> +1 step into a zero crossing at exactly `tau*ln2`, so `delay` must read
|
||||||
`want = 0.002206 s` and **`ratio` between 0.7 and 1.4**. A `ratio` near 0 is a pass-through; a large one
|
`want = 0.002206 s` and **`ratio` between 0.7 and 1.4**. A `ratio` near 0 is a pass-through; a large one
|
||||||
means `a` is far too small, i.e. `dt` never arrived at the detector.
|
means `a` is far too small, i.e. `dt` never arrived at the detector. **This is the leg the mock's
|
||||||
|
`--square-harmonics 0` is for**: `want` is a *step* response, and the square rings by default, which moves
|
||||||
|
the crossing to `delay=0.002770 ratio=1.255` — inside the band, but no longer the figure beside it, and the
|
||||||
|
same on every run, so it is the shape and not the run. See the recipe in
|
||||||
|
[verification.md](verification.md). The band/hf legs need no such flag: they count the crossings of `sine`,
|
||||||
|
which the ripple does not touch at all — their counts read a few percent lower at this recipe's 1.3 MS/s
|
||||||
|
only because the extra harmonics cost the *producer*, which
|
||||||
|
[verification.md](verification.md) notes.
|
||||||
- `state` is `2` (Done) or `4` (Capped) on every leg. `3` (Truncated) on more than one leg means the pump loop
|
- `state` is `2` (Done) or `4` (Capped) on every leg. `3` (Truncated) on more than one leg means the pump loop
|
||||||
is being overtaken -- nothing to fix in the feature, but say so.
|
is being overtaken -- nothing to fix in the feature, but say so.
|
||||||
|
|
||||||
|
|||||||
+92
-21
@@ -13,21 +13,24 @@ xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
|||||||
./build/udpscope --server 127.0.0.1 --port 8000
|
./build/udpscope --server 127.0.0.1 --port 8000
|
||||||
```
|
```
|
||||||
|
|
||||||
Two baselines every run is read against. A clean build emits **four** warnings and no more: two
|
Two baselines every run is read against. A clean build emits **three** warnings and no more: two
|
||||||
`-Wmissing-field-initializers` on `MathNode::source` and `-Woverlength-strings` on **both**
|
`-Wmissing-field-initializers` on `MathNode::source` and one `-Woverlength-strings` on the icons'
|
||||||
generated GResource `.c` files — the help pages' (**514660** bytes) and the icons', whose blob is
|
generated GResource `.c`, whose blob is **12352** against C99's 4095-byte string-literal limit with
|
||||||
**12352** against C99's 4095-byte string-literal limit with the seven action icons and the two app icons
|
the seven action icons and the two app icons that remain (a note about a generated file, which GCC
|
||||||
that remain (a note about a generated file, which GCC itself supports) -- re-measured when
|
itself supports). The *fourth* was the help pages' generated `.c` (**516044** bytes); the manual is
|
||||||
the tab work changed the set (that icon left, the two tab glyphs arrived, and this file's
|
now a Mallard set installed for Yelp rather than a GResource compiled into the binary, so that
|
||||||
help page was rewritten), which did not move the count; re-measure by touching each `*.gresource.xml`
|
bundle — and its warning — is gone, and the baseline moved from four to **three**. Re-measure by
|
||||||
under `src/` and rebuilding. The fifth warning that used to appear here was an unused
|
touching each `*.gresource.xml` under `src/` and rebuilding; a third `-Wmissing-field-initializers`
|
||||||
|
or any other warning is a finding. (The fifth warning that used to appear here was an unused
|
||||||
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
|
`output_built` in `math_probe()`; it was **deleted** as dead code — redundant with the
|
||||||
`output_after_add` already printed on the same line — and that deletion is what dropped the baseline
|
`output_after_add` already printed on the same line — which dropped the baseline from five to
|
||||||
from five to four. Nothing started reading it. And an exit-dump run emits **no** `Gtk-CRITICAL` at
|
four.) And an exit-dump run emits **no** `Gtk-CRITICAL` at all, nor any `Gtk-WARNING`: the dump's
|
||||||
all. A fifth warning, or any critical, is a finding.
|
`gtk warnings:` line counts GTK's own log (through a writer function — GTK is built with structured
|
||||||
|
logging, so the handler API never sees its messages) and must read `0`; see
|
||||||
|
[probe-readings.md](probe-readings.md).
|
||||||
|
|
||||||
An *incremental* build reads **two** of those four: the generated GResource `.c` files are not
|
An *incremental* build reads **one** of those three: the generated GResource `.c` is not recompiled
|
||||||
recompiled when only `src/` changed, so their `-Woverlength-strings` do not reappear.
|
when only `src/` changed, so its `-Woverlength-strings` does not reappear.
|
||||||
|
|
||||||
A third reading every run offers for free: **how many `[udpscope] buffer allocated:` lines it
|
A third reading every run offers for free: **how many `[udpscope] buffer allocated:` lines it
|
||||||
printed.** The line is not behind a probe — it is in every build — and one line is one *real* change
|
printed.** The line is not behind a probe — it is in every build — and one line is one *real* change
|
||||||
@@ -39,7 +42,8 @@ That second baseline used to be "exactly two, `gtk_widget_get_height` and `gtk_w
|
|||||||
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
|
from the dump asking a destroyed widget for its size", and tolerating them was a mistake: the dump
|
||||||
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
|
runs from the destroy handler, so the widget pointer is **freed memory**, and a critical is only
|
||||||
the lucky reading of it. A run that had built and destroyed other windows first —
|
the lucky reading of it. A run that had built and destroyed other windows first —
|
||||||
`UDPSCOPE_PROBE_HELP_MS`, which opens three — has had that memory reused, and the same two calls
|
`UDPSCOPE_PROBE_HELP_MS`, which opens the About and shortcut dialogs and then closes them — has had
|
||||||
|
that memory reused, and the same two calls
|
||||||
then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a
|
then **SIGSEGV in the middle of the dump**, which reads as a truncated dump rather than as a
|
||||||
crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the
|
crash in a debug line. Every size the dump prints is therefore captured on the *tick*, while the
|
||||||
window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the
|
window is alive (`App::dbg_*`), and **no `gtk_widget_*` call may appear in the dump** — the
|
||||||
@@ -80,8 +84,18 @@ precedent, not an exception.
|
|||||||
collecting anything -- that is how the first baseline of the stall below read 0.4 ms).
|
collecting anything -- that is how the first baseline of the stall below read 0.4 ms).
|
||||||
The values are in the exit dump as `measurements: <type>=<value>`; on the fast mock
|
The values are in the exit dump as `measurements: <type>=<value>`; on the fast mock
|
||||||
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read
|
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`) a unit sine must read
|
||||||
`peak to peak=2`, `amplitude=1`, `average=0` and **`top=0.636620`** -- 2/pi, the half-wave
|
`peak to peak=2` **exactly**, `average=0`, and the two flat-level types as their definition
|
||||||
mean of a sine, which is the check that the threshold types are not merely plausible.
|
says: **`top=0.957988`** and `amplitude=1.915983`. `top` is no longer 2/pi -- it is the *flat*
|
||||||
|
level of the upper side, read from the density of the window's buckets, and a sine has no
|
||||||
|
plateau so the densest band is at its peak; `amplitude` is `top - bottom` (a scope's
|
||||||
|
`Vamplitude`), not the half-swing it once was. The check that the flat levels are *levels* and
|
||||||
|
not extremes is the same probe against the mock's `square` (`--square-harmonics 30`, the
|
||||||
|
control being `0`): `top`/`bottom` `0.999983`/`-0.999983` rippled against `1.000000`/`-1.000000`
|
||||||
|
ideal, `amplitude` `1.999966` against `2.000000`, while `peak to peak` reads `2.358701` rippled
|
||||||
|
against `2.000000` ideal -- the ±1.179 edges'
|
||||||
|
overshoot, kept by the extremes and excluded by the levels. The per-type values, the reason for
|
||||||
|
each, and the cost of the histogram are in
|
||||||
|
[invariants-measurements.md](invariants-measurements.md).
|
||||||
`[measure-trace] view=[..] <type>=<value>... marks=N level:V/time:V...` is a repeating line
|
`[measure-trace] view=[..] <type>=<value>... marks=N level:V/time:V...` is a repeating line
|
||||||
(250 ms) from the probe: the pane's numbers beside the *current* window and the marks actually
|
(250 ms) from the probe: the pane's numbers beside the *current* window and the marks actually
|
||||||
placed, which is the only way a stale result or a mark that should not be drawn shows up as a
|
placed, which is the only way a stale result or a mark that should not be drawn shows up as a
|
||||||
@@ -102,7 +116,11 @@ precedent, not an exception.
|
|||||||
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples.
|
(`tools/mock_udpstreamer.py --rate 1300000 --batch 2000`), where a window is ~2 M samples.
|
||||||
`--noise 0.1` there reads **2.0%** at 1.96 ms a recompute against the clean 0.1% at 1.41 ms, and
|
`--noise 0.1` there reads **2.0%** at 1.96 ms a recompute against the clean 0.1% at 1.41 ms, and
|
||||||
that is the interferer's own cost rather than a regression -- the figure to compare a change
|
that is the interferer's own cost rather than a regression -- the figure to compare a change
|
||||||
against is the **clean** one.
|
against is the **clean** one. `raw` is also the guard on the flat-level types, which are the one
|
||||||
|
thing that reads a second time: `top`/`bottom`/`amplitude` fold a histogram out of the **same**
|
||||||
|
fetched bucket list, so they add buckets and not samples (measured: `raw=0.1%` and `2.1%` with
|
||||||
|
the interferer, against `0.1%`/`2.1%` before those types had a flat level, for 0.44→0.50 ms and
|
||||||
|
0.52→0.65 ms a pass -- the buckets are the budget).
|
||||||
Note the **final** `measurements:` line and `[measure-trace]`'s last lines may read `--`:
|
Note the **final** `measurements:` line and `[measure-trace]`'s last lines may read `--`:
|
||||||
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so the dump can land in `Waiting`
|
`UDPSCOPE_EXIT_AFTER_MS` arms the trigger before it dumps, so the dump can land in `Waiting`
|
||||||
with nothing bound and the axis at its default. The race-free reading is an earlier
|
with nothing bound and the axis at its default. The race-free reading is an earlier
|
||||||
@@ -194,8 +212,8 @@ precedent, not an exception.
|
|||||||
nothing. Its four lines are in
|
nothing. Its four lines are in
|
||||||
[probe-readings.md](probe-readings.md).
|
[probe-readings.md](probe-readings.md).
|
||||||
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
|
- `UDPSCOPE_EXPORT_MS` (+ optional `UDPSCOPE_EXPORT_PATH`, default
|
||||||
`/tmp/udpscope-export`) — collect the export table and write it both ways, skipping
|
`/tmp/udpscope-export`) — collect the export table and write it all three ways (`.npy`,
|
||||||
only the file chooser.
|
`.csv`, `.mf3`), skipping only the file chooser.
|
||||||
|
|
||||||
- `UDPSCOPE_PROBE_CONDITION_MS` — arms a qualified trigger against the mock and reports the latched
|
- `UDPSCOPE_PROBE_CONDITION_MS` — arms a qualified trigger against the mock and reports the latched
|
||||||
`t0`, the measured pulse or band width, and sample values for the window condition. Schedules its
|
`t0`, the measured pulse or band width, and sample values for the window condition. Schedules its
|
||||||
@@ -302,6 +320,32 @@ as distinct from the fault injection below, which exercises ingest:
|
|||||||
`GtkMenuButton`-owned submenu failed, its row going dead on the second click. Each view is
|
`GtkMenuButton`-owned submenu failed, its row going dead on the second click. Each view is
|
||||||
measured a step after the click that swapped it in, because a popover allocates its new child on
|
measured a step after the click that swapped it in, because a popover allocates its new child on
|
||||||
the next layout pass.
|
the next layout pass.
|
||||||
|
- `UDPSCOPE_PROBE_HELPUI_MS` — the help affordance layer, end to end, in ten steps 300–400 ms apart:
|
||||||
|
the **first-run auto-start** (run it once with a throwaway `XDG_CONFIG_HOME` to see `auto=1`, and
|
||||||
|
again against the same one to see it not repeat), the menu's seven rows and the **four entries of
|
||||||
|
the Help submenu**, the **contextual-help toggle** with the count of "(i)" affordances on either side
|
||||||
|
of it, the tour **started from the probe** and stepped forwards, back and dismissed, and `help
|
||||||
|
figures:` read in both of the trigger pane's modes. Its lines and what each asserts are in
|
||||||
|
[probe-readings.md](probe-readings.md); the two things to read first are **`hints=0`** while the
|
||||||
|
toggle is off (off is *gone*, not greyed) and **`controllers=12 (was 12)`** after the dismissal (no
|
||||||
|
lingering input grab — a tour of ordinary overlay widgets takes none), and **`hint=12x12`** is the
|
||||||
|
size the shrunk "(i)" must keep (the default menu-button mark is 34x34). The `collapsed search:` line
|
||||||
|
is the header hint read on the search block's expander while it is collapsed. The chooser's own tour is
|
||||||
|
the other half and has its own entry points — see the **chooser** bullet below. Give it
|
||||||
|
`UDPSCOPE_EXIT_AFTER_MS=9500 UDPSCOPE_EXIT_DUMP=1` to get the `help layer:`/`help figures:` dump
|
||||||
|
lines as well, and note the tour **auto-starts** in any run whose settings file does not carry
|
||||||
|
`tour_seen` — **in the source chooser when there is one**, in the window otherwise (see the chooser
|
||||||
|
bullet) — so a probe run against a fresh config directory will have it on screen for the first
|
||||||
|
second and a half, and that start *writes* `tour_seen` to whichever config directory the run is
|
||||||
|
using, which is why the second run against the same one reads `auto=0`.
|
||||||
|
- `UDPSCOPE_PROBE_TOUR_SURFACE_MS` — the tour's *surface* switch: it puts the sidebar on **Measure**,
|
||||||
|
starts the window's tour, and walks it a stop per tick to the signals stop, reading the sidebar's
|
||||||
|
page and the spotlight/card where the walk arrives. It then puts the sidebar *away* and starts the
|
||||||
|
tour again, which is the same filter's other case (a collapsed pane drops the stops a page switch
|
||||||
|
could have revealed). `[toursurface] signals stop=4/5 … side_tab=signals … contains=1` and
|
||||||
|
`[toursurface] collapsed: side_stack_mapped=0 steps=2 signals_stop=0` are the two assertions; its
|
||||||
|
lines are in [probe-readings.md](probe-readings.md). Run it alone with
|
||||||
|
`UDPSCOPE_EXIT_AFTER_MS=9000 UDPSCOPE_EXIT_DUMP=1` against a live source.
|
||||||
- `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the
|
- `UDPSCOPE_PROBE_DEFAULTS_MS` — opens **Calibrate** and reads every spin button in it and in the
|
||||||
trigger panel: `[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01`. A
|
trigger panel: `[defaults] calibrate: 1 0 model=1 0 | trigger: 0.001 0.002 0 0.001 0 50 0.01`. A
|
||||||
`GtkSpinButton` starts at its range's *minimum*, so a `-1e12` here is an unseeded button the
|
`GtkSpinButton` starts at its range's *minimum*, so a `-1e12` here is an unseeded button the
|
||||||
@@ -313,7 +357,23 @@ as distinct from the fault injection below, which exercises ingest:
|
|||||||
entry per configuration (`host:port/name … buffer= relative= note='…'`), and
|
entry per configuration (`host:port/name … buffer= relative= note='…'`), and
|
||||||
`UDPSCOPE_PROBE_PICK_MS=800` opens the chooser, picks the first row and prints
|
`UDPSCOPE_PROBE_PICK_MS=800` opens the chooser, picks the first row and prints
|
||||||
`[sources] picked host:port … buffer=64 relative=0 capture=0 config=… note='…'` — the settings the
|
`[sources] picked host:port … buffer=64 relative=0 capture=0 config=… note='…'` — the settings the
|
||||||
configuration carried, into the `Config` the model is built from. A legacy file
|
configuration carried, into the `Config` the model is built from. **With no `--server` and no probe
|
||||||
|
the chooser blocks in its own nested loop** (no frame to end and no dump), so a chooser run is
|
||||||
|
always this probe. It also prints the dialog's frame *and its own help layer*:
|
||||||
|
`[sources] chooser ui: … hints=[2 {chooser_sources chooser_waveform}] hint=12x12` with the setting
|
||||||
|
on, `hints=[0 {}] hint=0x0` with it off. `UDPSCOPE_PROBE_CHOOSER_HELP=1` drives the **Enable
|
||||||
|
contextual help** row and reads the file back; `UDPSCOPE_PROBE_CHOOSER_TOUR=1` drives the **Guided
|
||||||
|
tour** row and walks the chooser's own tour to its last stop, printing `[sources] chooser tour: …`
|
||||||
|
and the handoff (`tour_pending=1`); `UDPSCOPE_PROBE_CHOOSER_TOUR_MS=<ms>` walks it a stop per tick
|
||||||
|
instead, printing the page the stack shows at every stop and stepping **Back** off the stop that
|
||||||
|
stands on the waveforms page — the reading for the surface a stop changes. It starts the walk
|
||||||
|
itself when one is not running, so it does not need a first-run config; pair it with
|
||||||
|
`UDPSCOPE_PROBE_PICK_MS` after the walk so the application's half follows. A working run: seed a
|
||||||
|
source once (`./build/udpscope --server 127.0.0.1 --port N` against a mock, under the throwaway
|
||||||
|
`XDG_CONFIG_HOME`), then run bare with
|
||||||
|
`UDPSCOPE_PROBE_CHOOSER_TOUR_MS=2000 UDPSCOPE_PROBE_PICK_MS=4500 UDPSCOPE_EXIT_AFTER_MS=12000
|
||||||
|
UDPSCOPE_EXIT_DUMP=1`. Its lines and what each asserts are in
|
||||||
|
[probe-readings.md](probe-readings.md). A legacy file
|
||||||
(`<host>_<port>.toml`) produces `layout loaded from …/<host>_<port>/default.toml`, an `active`
|
(`<host>_<port>.toml`) produces `layout loaded from …/<host>_<port>/default.toml`, an `active`
|
||||||
holding `default`, and the old file renamed `.imported`.
|
holding `default`, and the old file renamed `.imported`.
|
||||||
- **The loading window covers the gap, and only on the path that asked.** The same
|
- **The loading window covers the gap, and only on the path that asked.** The same
|
||||||
@@ -585,13 +645,24 @@ same run).
|
|||||||
```bash
|
```bash
|
||||||
cd /home/martino/Projects/tools/udpscope
|
cd /home/martino/Projects/tools/udpscope
|
||||||
python3 tools/mock_udpstreamer.py --port 8108 --rate 1300000 --batch 1000 --duration 20 \
|
python3 tools/mock_udpstreamer.py --port 8108 --rate 1300000 --batch 1000 --duration 20 \
|
||||||
--spike-every 0.005 --spike-amp 0.5 --spike-width 2 > /tmp/noise-mock.log 2>&1 &
|
--spike-every 0.005 --spike-amp 0.5 --spike-width 2 --square-harmonics 0 \
|
||||||
|
> /tmp/noise-mock.log 2>&1 &
|
||||||
sleep 1.5
|
sleep 1.5
|
||||||
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=18000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
xvfb-run -a env UDPSCOPE_EXIT_AFTER_MS=18000 UDPSCOPE_ENABLE_ALL_MS=1000 \
|
||||||
UDPSCOPE_PROBE_NOISE_MS=4000 \
|
UDPSCOPE_PROBE_NOISE_MS=4000 \
|
||||||
./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL'
|
./build/udpscope --server 127.0.0.1 --port 8108 2>&1 | grep -E 'noise-probe|CRITICAL'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`--square-harmonics 0` is the one flag in that command that is not about the sine, and it is what keeps the
|
||||||
|
probe's last reading exact: the group-delay leg measures a one-pole's *step* response on `square`, and the
|
||||||
|
mock's square rings by default (see [build-and-run.md](build-and-run.md)). A band-limited edge moves that
|
||||||
|
crossing off `tau*ln2` — measured `delay=0.002770 ratio=1.255` against the ideal step's `0.002206 / 1.000`,
|
||||||
|
and the same figure on every run, so it is a property of the shape and not of the run. The leg's assertion is
|
||||||
|
the `0.7..1.4` band, which the ripple keeps, but the ideal step is what keeps the *number* the line prints
|
||||||
|
beside `want` as well. The other legs measure event counts on the sine, which the ripple does not touch; the
|
||||||
|
counts do read a few percent lower under the ripple at this rate, for the producer-cost reason
|
||||||
|
[build-and-run.md](build-and-run.md) gives.
|
||||||
|
|
||||||
`UDPSCOPE_PROBE_NOISE_MS` is **not the time its readings are taken**. It times the probe's first stage, which
|
`UDPSCOPE_PROBE_NOISE_MS` is **not the time its readings are taken**. It times the probe's first stage, which
|
||||||
installs a math graph through `apply_math_channels()` -- and that is a *structural rebuild* that replaces the
|
installs a math graph through `apply_math_channels()` -- and that is a *structural rebuild* that replaces the
|
||||||
buffer and drops its history. Every reading the probe takes is over retained history, so the rebuild has to
|
buffer and drops its history. Every reading the probe takes is over retained history, so the rebuild has to
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="cursors">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#analysis"/>
|
||||||
|
<link type="seealso" xref="measurements"/>
|
||||||
|
<desc>The shared vertical cursor pair and the per-row horizontal rulers, their four modes,
|
||||||
|
and how a reading is formatted.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Cursors</title>
|
||||||
|
|
||||||
|
<p>Cursors read values off the traces. There are two kinds: one <em>shared vertical
|
||||||
|
pair</em> that every row carries at the same time, and <em>horizontal rulers</em>, one pair
|
||||||
|
per row, that belong to that row alone.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Press the cursors button in the toolbar, or <key>c</key>.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The pane below the plot shows the readings and owns a <gui>Reset</gui> button.
|
||||||
|
Drag a marker to move it.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The mode dropdown offers four modes: <gui>vertical</gui> (the shared time pair),
|
||||||
|
<gui>horizontal</gui> (the per-row rulers), <gui>both</gui>, and <gui>mix</gui>.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>The vertical pair is <em>one</em> time for the whole figure, so moving it on
|
||||||
|
any row moves it on all of them. That is what makes <code>dt</code> meaningful across
|
||||||
|
rows.</p></item>
|
||||||
|
<item><p>A ruler belongs to its row and nothing shares it, so two rows' rulers are
|
||||||
|
independent.</p></item>
|
||||||
|
<item><p><gui>mix</gui> is the reading mode: one line per <em>bound</em> trace, with the
|
||||||
|
trace's own values at each marker and their difference. A slot with a name but no
|
||||||
|
channel behind it is not a trace and gets no line.</p></item>
|
||||||
|
<item><p>A value is the <em>nearest stored sample's</em>, never a number between two
|
||||||
|
samples. Zoom in past the sample spacing and the line between two samples is something
|
||||||
|
the plot drew, not data — so a reading off it would be the drawing. The hover crosshair
|
||||||
|
reads the same way.</p></item>
|
||||||
|
<item><p>Every reading carries a <em>metric prefix</em> so the digits sit in
|
||||||
|
<code>1..999</code>: <code>10.52 mV</code>, not <code>0.01 V</code>. Two decimals is all
|
||||||
|
an instrument landing on an <code>f32</code> sample can claim, so without the prefix a
|
||||||
|
small value would read <code>0.00</code> and the reading would be gone. A stream that
|
||||||
|
declares no unit gets the prefix anyway, written tight against the number
|
||||||
|
(<code>585.24m</code>) — the space separates the number from its unit, and a spaced
|
||||||
|
<code>m</code> with nothing after it would read as metres.</p></item>
|
||||||
|
<item><p>A marker past the newest sample reads <code>--</code>. With only one marker
|
||||||
|
over data the row reads <code>partial</code>: a difference between a number and nothing
|
||||||
|
is not a number.</p></item>
|
||||||
|
<item><p>In <gui>mix</gui> each marker also places a crossing annotation per trace,
|
||||||
|
which is what lets you see on the plot which sample a number came from. It is <em>on
|
||||||
|
that sample</em>, so when the marker falls between two it sits a little off its own
|
||||||
|
line: the line is where you put the marker, the dot is the datum being quoted. The hover
|
||||||
|
crosshair's mark snaps the same way, and is drawn larger than a trace's sample dots so
|
||||||
|
it does not read as one more sample.</p></item>
|
||||||
|
<item><p>The time row exists only in the modes that show the vertical pair.
|
||||||
|
<gui>horizontal</gui> has no time row, by design.</p></item>
|
||||||
|
<item><p>A cursor <em>stays where you put it</em> while a live view rolls under it,
|
||||||
|
which is what a bench scope does: its <code>T0</code> is an age, so a marker left at
|
||||||
|
<code>-1.5s</code> keeps reading the sample a second and a half old as the stream
|
||||||
|
advances. A measurement's marks do the opposite — they travel with the sample they
|
||||||
|
measured — because a cursor is a question you asked and a mark is an answer about one
|
||||||
|
particular sample.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>Three traces in <gui>mix</gui> mode with both markers over data: 3 lines and 6 crossing
|
||||||
|
marks, each line reading like <code>sine 0.31 V -0.84 V -1.15 V</code>. Move the second
|
||||||
|
marker past the newest sample and the third column reads <code>--</code>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="export">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#output"/>
|
||||||
|
<link type="seealso" xref="sessions"/>
|
||||||
|
<desc>Writing the visible range of every plotted trace to NumPy, CSV or MDF 3.30, and what
|
||||||
|
each format carries.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Saving and export</title>
|
||||||
|
|
||||||
|
<p>Export writes the <em>visible range</em> of every plotted trace to a file: NumPy
|
||||||
|
<file>.npy</file>, <file>.csv</file>, or <gui>MDF 3.30</gui> (<file>.mf3</file> or
|
||||||
|
<file>.mdf</file>), chosen by the extension you type.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Pick <gui>Export visible range…</gui> in the overflow menu, or press
|
||||||
|
<keyseq><key>Ctrl</key><key>E</key></keyseq>.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>A file chooser asks where; the format follows the path's extension.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>The table is collected at the moment you click, not when the chooser returns. A
|
||||||
|
live view keeps scrolling while a dialog is up, so a late collection would write a window
|
||||||
|
you never saw.</p></item>
|
||||||
|
<item><p>The rows are the <gui>stored samples</gui>, never the LOD buckets the plot draws
|
||||||
|
from. A row count far below the span times the stored rate means something cut the table
|
||||||
|
from the pyramid.</p></item>
|
||||||
|
<item><p>The stored rate is not always the wire rate. A window long enough to need
|
||||||
|
decimation is stored averaged at a lower rate, and the export carries <em>that</em> —
|
||||||
|
read the status bar's ⓘ popover for the figure before trusting a sample count.</p></item>
|
||||||
|
<item><p>Two time columns. <code>time</code> is the display timeline — negative ages
|
||||||
|
ending at 0 while streaming, offsets from the trigger edge while an event is held — so it
|
||||||
|
is the x you read a value at on screen. <code>abs_seconds</code> is the source's own
|
||||||
|
clock for the same sample, which is what lets two exports taken in different modes line
|
||||||
|
up. Both are strictly increasing and evenly spaced; duplicate or backwards stamps would
|
||||||
|
mean the file held two clocks.</p></item>
|
||||||
|
<item><p>An export taken <em>mid-capture</em> writes the partly filled window that is on
|
||||||
|
screen, which is shorter than the event span. Export outside a capture if you want the
|
||||||
|
whole event.</p></item>
|
||||||
|
<item><p><gui>MDF</gui> exists for handing a window to another tool, and its shape follows
|
||||||
|
the contract that reader uses: one channel group, every channel record-interleaved, the
|
||||||
|
master channel as the x axis and the unit in the channel metadata. So there the
|
||||||
|
<em>master is <code>abs_seconds</code></em>, named <code>time</code> — the clock that is
|
||||||
|
the same in every display mode — and the display axis goes out beside it as an ordinary
|
||||||
|
channel, <code>time_relative</code>. Both are seconds; each signal carries the source's
|
||||||
|
own unit. That is also what <gui>ScryLab</gui> reads: channel name → signal name, master
|
||||||
|
→ x axis, unit → the signal's unit.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
|
||||||
|
(<code>time</code>, <code>abs_seconds</code>, three signals). The <file>.npy</file> is
|
||||||
|
<code>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</code> bytes. Read it back with
|
||||||
|
<code>pd.DataFrame(np.load(path))</code>. The <file>.mf3</file> is bigger — a block per
|
||||||
|
channel plus a comment — <code>272 + comment + rows·record + 28 + channels·274 + 30</code>
|
||||||
|
bytes, with <code>record = 16 + 4·cols</code> and <code>channels = cols + 2</code> for the
|
||||||
|
master and the relative axis. Read that one back with <code>asammdf.MDF(path)</code> or in
|
||||||
|
ScryLab.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 146 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
@@ -0,0 +1,67 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="guide" style="task"
|
||||||
|
id="index">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<desc>udpscope plots a live UDPStreamer source as one figure of up to eight stacked
|
||||||
|
plot rows sharing a single time axis, with a trigger, cursors, measurements, math
|
||||||
|
channels and an export. This manual covers connecting, the window and its panels,
|
||||||
|
navigating and pausing the plot, the trigger and its event cache, analysis, and
|
||||||
|
saving and exporting.</desc>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>udpscope</title>
|
||||||
|
|
||||||
|
<section id="main">
|
||||||
|
<title>Getting started</title>
|
||||||
|
<list>
|
||||||
|
<item><p><link xref="overview">Overview</link> — what udpscope is, how it reaches a
|
||||||
|
source, the window and its panels, and a first five minutes.</p></item>
|
||||||
|
<item><p><link xref="source">Choosing a source</link> — the command line, the
|
||||||
|
chooser, and opening an exported waveform instead of a live stream.</p></item>
|
||||||
|
<item><p><link xref="signals">Signals and calibration</link> — how a signal becomes
|
||||||
|
a channel, what starts acquisition, and how a scale and offset are applied.</p></item>
|
||||||
|
<item><p><link xref="layout">Rows, layout and drag & drop</link> — up to eight
|
||||||
|
rows, four traces each, and the layout editor.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="viewing">
|
||||||
|
<title>Viewing</title>
|
||||||
|
<list>
|
||||||
|
<item><p><link xref="window">Time window</link> — the one control that changes the
|
||||||
|
window, and the decimation a long window causes.</p></item>
|
||||||
|
<item><p><link xref="navigate">Navigating the plot</link> — panning, zooming and
|
||||||
|
fitting with the mouse and the keyboard.</p></item>
|
||||||
|
<item><p><link xref="pause">Pause and play</link> — freezing the display without
|
||||||
|
stopping the stream, and how long you have been looking.</p></item>
|
||||||
|
<item><p><link xref="trigger">Trigger</link> — the four trigger shapes, arming,
|
||||||
|
hold-off, the event cache, and searching the buffer.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="analysis">
|
||||||
|
<title>Analysis</title>
|
||||||
|
<list>
|
||||||
|
<item><p><link xref="cursors">Cursors</link> — the shared vertical pair and the
|
||||||
|
per-row horizontal rulers.</p></item>
|
||||||
|
<item><p><link xref="measurements">Measurements</link> — a statistic of the visible
|
||||||
|
window, its on-plot marks, and its statistics line.</p></item>
|
||||||
|
<item><p><link xref="math">Math channels</link> — building a derived channel from a
|
||||||
|
graph of source nodes, operations and one output.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="output">
|
||||||
|
<title>Output and state</title>
|
||||||
|
<list>
|
||||||
|
<item><p><link xref="export">Saving and export</link> — the visible range to
|
||||||
|
<file>.npy</file>, <file>.csv</file> or <file>.mf3</file>.</p></item>
|
||||||
|
<item><p><link xref="sessions">Sessions and settings</link> — the per-source
|
||||||
|
configuration files and what they carry.</p></item>
|
||||||
|
<item><p><link xref="status">Status bar and diagnostics</link> — the glance line, the
|
||||||
|
ⓘ popover, and the activity badge.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="layout">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#main"/>
|
||||||
|
<link type="seealso" xref="signals"/>
|
||||||
|
<desc>How the figure's rows and traces are arranged, how to drop a signal onto a row, and
|
||||||
|
how the layout editor adds and removes rows.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Rows, layout and drag & drop</title>
|
||||||
|
|
||||||
|
<p>The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share
|
||||||
|
one time axis and one pair of side margins, so their y labels line up, their data areas
|
||||||
|
start and end on the same two columns, and their windows cannot drift apart. The left
|
||||||
|
margin holds the y labels; the right one is empty, and is there so the newest tick's label
|
||||||
|
and the newest sample's ink have somewhere to land instead of sitting on the frame.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Drag a signal's chip out of the side panel (<key>s</key>) and drop it on a row to
|
||||||
|
add a trace.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Press <key>l</key>, or pick <gui>Layout editor</gui> from the overflow menu, to add
|
||||||
|
and remove rows: a <gui>×</gui> appears at the top left of each row and a <gui>+</gui>
|
||||||
|
on each boundary between rows, including above the first and below the last.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The editor's own button in the top-right corner leaves it.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>A plot's own <gui>right-click menu</gui> (the same compact rows as the app's
|
||||||
|
menu) holds the y-axis scale, the two fits, and <gui>Remove signal</gui>, which swaps
|
||||||
|
the menu to that plot's traces plus <gui>All traces on this plot</gui> — with a
|
||||||
|
<gui>Back</gui> row to return. That is how a trace comes off a row without going through
|
||||||
|
the layout editor.</p></item>
|
||||||
|
<item><p>A drop <em>adds</em> a trace to the row's next free slot; it does not replace
|
||||||
|
one. With four traces already on a row the drop is refused.</p></item>
|
||||||
|
<item><p>Inserting a row moves the rows below it <em>with their contents</em>. Inserting
|
||||||
|
above row 1 puts what row 1 plotted onto row 2 and leaves the new row empty — the rows
|
||||||
|
are not renumbered under you.</p></item>
|
||||||
|
<item><p>A trace is remembered by the signal's <em>name</em>, not by its channel number,
|
||||||
|
because a new CONFIG or an enable renumbers the channels and an index would silently
|
||||||
|
re-point the trace.</p></item>
|
||||||
|
<item><p>Adding or removing a row rebuilds the figure, which is cheap, but it also
|
||||||
|
re-deals every trace. A frozen trigger event survives the rebuild: the pinned window is
|
||||||
|
restated rather than re-framed on the live edge.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>With three traces on row 1, pressing <key>l</key> shows 3 buttons (one <gui>×</gui>,
|
||||||
|
two <gui>+</gui>). Clicking the <gui>+</gui> above row 1 gives 2 rows and 5 buttons, with
|
||||||
|
the three traces now on row 2 — the layout probe reports exactly this as
|
||||||
|
<code>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</code>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="math">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#analysis"/>
|
||||||
|
<link type="seealso" xref="signals"/>
|
||||||
|
<desc>Building a derived channel from a graph of source nodes, operations and one output,
|
||||||
|
and how the graph is evaluated.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Math channels</title>
|
||||||
|
|
||||||
|
<p>A math channel is a signal computed from other signals. You build it as a small graph —
|
||||||
|
source nodes, operations, one output — and udpscope then treats the result as an ordinary
|
||||||
|
channel.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Press the math button in the toolbar, or <key>g</key>.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The pane's <gui>+</gui> adds a channel, and opening one shows the graph editor:
|
||||||
|
nodes as boxes, click a box to select it and edit its parameters beside the canvas.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Inside the editor, <gui>add node</gui> is a menu button whose popover picks the op,
|
||||||
|
so the node arrives as the operation you asked for.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="gestures">
|
||||||
|
<title>Graph gestures</title>
|
||||||
|
<p><gui>drag</gui> from an output port to an input port to wire them; drag a wired input
|
||||||
|
port off its connection to detach it; click a wire and press <key>Delete</key> to remove
|
||||||
|
it; drag a box to move it. <gui>Wheel</gui> zooms about the pointer. <gui>Drag the
|
||||||
|
background</gui> to pan. <key>f</key> fits all nodes into view;
|
||||||
|
<keyseq><key>Ctrl</key><key>0</key></keyseq> resets the view. <gui>tidy</gui>, the button
|
||||||
|
beside <gui>fit</gui> in the editor, lays the graph out in topological columns and frames
|
||||||
|
it. The orange dot marks the output node; click <gui>set as output</gui> to move it.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>The graph is evaluated as an ordered list, so a node may only read ones
|
||||||
|
<em>above</em> it. A wire drawn right to left re-sorts the list silently so the graph
|
||||||
|
stays valid. A cycle is unrepresentable: the connection is refused rather than created,
|
||||||
|
and the line beneath the form says why — a loop, or a node asked to read itself.</p></item>
|
||||||
|
<item><p>A derived channel <em>is</em> a channel. It appears in the signal panel, can be
|
||||||
|
plotted, measured, exported, and used as a source for another math channel.</p></item>
|
||||||
|
<item><p>The math runs on the <em>stored</em> samples — decimated and averaged — so a
|
||||||
|
cutoff you type in Hz means the rate the filter will actually run at. Change the window
|
||||||
|
across a decimation boundary and the coefficients are rebuilt for the new rate.</p></item>
|
||||||
|
<item><p>A source may only name an <em>acquired</em> channel. The stream's timestamp
|
||||||
|
signal is not one: a graph sourced from it validates and then reads NaN for ever, so the
|
||||||
|
dropdown does not offer it.</p></item>
|
||||||
|
<item><p>The output defaults to the <em>last</em> node, and a node added to a linear
|
||||||
|
graph becomes the output. Choosing an earlier node explicitly is respected.</p></item>
|
||||||
|
<item><p>Every numeric field starts at its own identity value — gain 1, offset 0 — not
|
||||||
|
at the bottom of its range.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="why">
|
||||||
|
<title>Why an operation sometimes does nothing</title>
|
||||||
|
<p>A graph's <em>output</em> is the node whose value becomes the channel, and it is drawn
|
||||||
|
as the orange dot. A new graph is built source, then operation, and the last node added
|
||||||
|
becomes the output — but a graph whose output is left on the source is valid and simply
|
||||||
|
passes its input through, which is the usual reason a gain or a filter appears to have no
|
||||||
|
effect. <gui>Set as output</gui> moves the dot.</p>
|
||||||
|
|
||||||
|
<p><gui>Apply</gui> is insensitive while the graph is incomplete. A line beneath the form
|
||||||
|
says why: an input that is not connected, no output chosen, or a <em>channel</em> node
|
||||||
|
naming a signal the scope is not acquiring. Only the channels being acquired are offered,
|
||||||
|
because a source that is not acquired reads as no data for ever — and a graph that reads
|
||||||
|
NaN is harder to diagnose than one that would not save.</p>
|
||||||
|
|
||||||
|
<p>The filters are real filters. A math channel's samples are produced as the stream
|
||||||
|
arrives, once per stored slot, so a low pass carries the history a filter needs. A cutoff
|
||||||
|
typed in hertz is against the <em>stored</em> rate, which is the wire rate until a wide
|
||||||
|
window makes the buffer decimate — the status line's rate is that number, and the filters
|
||||||
|
are rebuilt whenever it changes.</p>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<title>The math channel editor</title>
|
||||||
|
<desc>Nodes as boxes on a canvas, the orange dot marking the output, and the selected
|
||||||
|
node's parameters beside it.</desc>
|
||||||
|
<media type="image" mime="image/png" src="figures/math-editor.png" width="938" height="420">
|
||||||
|
<p>A sine source wired through a gain into a filter, with the output on the last
|
||||||
|
node.</p>
|
||||||
|
</media>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p><code>[sine] → [gain x2]</code> gives a channel reading <code>y=[-2, 2]</code>. A
|
||||||
|
100 Hz low pass on the same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band
|
||||||
|
pass with Q=2 passes 200 Hz at 0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel
|
||||||
|
must hold exactly as many samples as the shared clock has time slots.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="measurements">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#analysis"/>
|
||||||
|
<link type="seealso" xref="cursors"/>
|
||||||
|
<desc>A measurement is a statistic of the visible window of one signal, with optional
|
||||||
|
on-plot marks and a per-measurement statistics line.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Measurements</title>
|
||||||
|
|
||||||
|
<p>A measurement is a statistic of the <em>visible window</em> of one signal, recomputed as
|
||||||
|
that window moves. The pane lists them with their values; each can also draw its result on
|
||||||
|
the plot.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Press the measurements button in the toolbar, or <key>m</key>.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The pane's <gui>+</gui> adds one; clicking a row opens the dialog where the signal,
|
||||||
|
the type, the threshold share and the on-plot marks are set, with <gui>Save</gui> and
|
||||||
|
<gui>Delete</gui>.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>The types are average, top, bottom, min, max, peak to peak, amplitude, pulse
|
||||||
|
duration, period and frequency. <gui>top</gui> and <gui>bottom</gui> are the waveform's
|
||||||
|
<em>flat</em> high and low levels: the level the signal dwells at, taken from the densest
|
||||||
|
band of the window on each side of the split. A band-limited edge overshoots and rings —
|
||||||
|
a nominal 2 V square reaches about ±1.18 at its edges — and a ripple or an overshoot is
|
||||||
|
therefore <em>not</em> what these read. <gui>amplitude</gui> is the delta between those two
|
||||||
|
levels, and <gui>peak to peak</gui> is the swing between the extremes (so on a rippled
|
||||||
|
square <gui>amplitude</gui> reads 2 V where <gui>peak to peak</gui> reads 2.36 V).</p></item>
|
||||||
|
<item><p><gui>top</gui> and <gui>bottom</gui> also read <em>near the peak</em> on a
|
||||||
|
waveform that has no flat part, which is the honest reading rather than a failure: a unit
|
||||||
|
sine has no plateau, so the densest band is at its peak and these read about ±0.96 — not
|
||||||
|
the 0.64 a mean over each half would give, and not ±1 either. Only a level the signal
|
||||||
|
actually dwells at is a level.</p></item>
|
||||||
|
<item><p>The <em>split</em> says which side a level is read from, so any share between the
|
||||||
|
two levels reads the same number; it is not a boundary the value depends on. The one case
|
||||||
|
where it is read directly is a window the scope cannot summarize by level — a window
|
||||||
|
shorter than a level's own resolution, or one read through <gui>hf</gui> — where
|
||||||
|
<gui>top</gui>/<gui>bottom</gui> fall back to the mean of each side of the split and
|
||||||
|
<gui>amplitude</gui> to the window's peak to peak.</p></item>
|
||||||
|
<item><p>The on-plot marks come in two kinds and the mix is the reading: a <em>level</em>
|
||||||
|
mark is a ruler at a value, a <em>time</em> mark is a marker at a position. An average
|
||||||
|
draws one level mark; a max draws a level mark <em>and</em> a time mark at the first
|
||||||
|
sample that reached it; <gui>peak to peak</gui> draws a ruler at each extreme and
|
||||||
|
<gui>amplitude</gui> a ruler at each of its two flat levels — the pair is the span, so
|
||||||
|
the two rulers <em>are</em> the measurement.</p></item>
|
||||||
|
<item><p>Marks cannot be dragged. A measurement mark says what the measurement measured,
|
||||||
|
so it is created unselectable — as is the trigger marker.</p></item>
|
||||||
|
<item><p>A pass is throttled to a cadence rather than run every frame, so a ruler over a
|
||||||
|
live view is <em>stepped</em>. The number and the ruler come from the same pass, so they
|
||||||
|
always agree.</p></item>
|
||||||
|
<item><p>An invalid pass keeps its <em>level</em> marks and drops its <em>time</em>
|
||||||
|
marks. A level is a property of the signal and outlives the window; a position inside
|
||||||
|
the window does not. That is what stops the rulers blinking off at a mode change.</p></item>
|
||||||
|
<item><p>A crossing is counted with <em>hysteresis</em>, not on a bare level: the
|
||||||
|
thresholds are a tenth of the window's peak to peak either side of the share, and an
|
||||||
|
edge is counted only once the signal has passed the far one. Period, frequency and pulse
|
||||||
|
duration all depend on it. The mid level is exactly where a waveform moves fastest, so
|
||||||
|
any interferer faster than the fundamental recrosses a single level several times per
|
||||||
|
real crossing — a 5 Hz sine with a 500 Hz tone at a tenth of the amplitude reads 39 Hz
|
||||||
|
without the band and 5 Hz with it. The band holds to an interferer a third of the
|
||||||
|
waveform's own size.</p></item>
|
||||||
|
<item><p>The band is clamped to stay inside the data, which is what keeps a threshold set
|
||||||
|
near an extreme usable: a fixed band there would sit above every sample and the
|
||||||
|
measurement would read nothing. A share near an extreme still leaves the band little
|
||||||
|
room, so it rejects less — which is one reason period and frequency offer no share to
|
||||||
|
set.</p></item>
|
||||||
|
<item><p>A window with too few crossings reads <code>invalid</code> / <code>--</code>
|
||||||
|
rather than a plausible invented number. Frequency over a window holding one edge is the
|
||||||
|
usual case.</p></item>
|
||||||
|
<item><p><gui>frequency</gui> is reported in Hz and <gui>period</gui>/<gui>pulse
|
||||||
|
duration</gui> in seconds; every other type carries the channel's own unit. A value is
|
||||||
|
written with a <em>metric prefix</em> so its digits sit in <code>1..999</code> —
|
||||||
|
<code>636.62 mV</code>, not <code>0.64 V</code> — including when the stream declares no
|
||||||
|
unit at all, which is always the case for a math channel: there the prefix goes tight
|
||||||
|
against the number (<code>636.62m</code>), since two decimals of an unprefixed small
|
||||||
|
value is <code>0.00</code>.</p></item>
|
||||||
|
<item><p>Cost is bounded by reading the summary pyramid the producer already maintains,
|
||||||
|
not the raw window: a bucket's sum is a sum, so the mean, the extremes and a level's own
|
||||||
|
mean are exact. <gui>top</gui>, <gui>bottom</gui> and <gui>amplitude</gui> read the
|
||||||
|
<em>density</em> of the window out of that same summary — which is what tells a flat level
|
||||||
|
from a ripple — so those three cost a second read of the summary and never of a sample.</p></item>
|
||||||
|
<item><p><gui>Statistics</gui> adds a second line to every measurement showing
|
||||||
|
<code>n</code>, the mean, the standard deviation and the range. Each observation is one
|
||||||
|
<em>recompute</em> of the pane, so <code>n</code> counts windows measured, not samples —
|
||||||
|
a mean frequency over many windows is what that claims to be. <gui>Reset</gui> clears
|
||||||
|
every accumulator and starts fresh. A change of display window, a CONFIG or a
|
||||||
|
measurement edit also resets; a pause, a resume, a zoom, a pan and the toggle itself do
|
||||||
|
not, so turning the line off to read the plot and back on again keeps the history.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>On a 1 Hz ±1 sine: <code>average=0</code>, <code>top=0.957988</code>,
|
||||||
|
<code>bottom=-0.957988</code> (the flat levels, near the peaks — a sine has no plateau),
|
||||||
|
<code>max=1</code>, <code>min=-1</code>,
|
||||||
|
<code>peak to peak=2</code>, <code>amplitude=1.915983</code>, <code>pulse duration=0.5 s</code>
|
||||||
|
(duty 0.5), <code>period=1 s</code>, <code>frequency=1 Hz</code>. On the same stream's
|
||||||
|
band-limited <code>square</code>, whose edges ring out to ±1.179: <code>top=0.999983</code>,
|
||||||
|
<code>bottom=-0.999983</code>, <code>amplitude=1.999966</code> against
|
||||||
|
<code>peak to peak=2.358701</code> — the levels are the plateau's and the extremes are the
|
||||||
|
edges'. Over a 2 M-sample window those passes read about 0.1% of the samples.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="navigate">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#viewing"/>
|
||||||
|
<link type="seealso" xref="window"/>
|
||||||
|
<desc>Panning, zooming and fitting the plot with the mouse and the keyboard, and what a
|
||||||
|
pinned view refuses to do.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Navigating the plot</title>
|
||||||
|
|
||||||
|
<p>The plot pans and zooms with the mouse and with the keyboard. Every gesture acts on the
|
||||||
|
row the pointer is over, and because the rows share one time axis, an x change moves all of
|
||||||
|
them.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<terms>
|
||||||
|
<item>
|
||||||
|
<title>Wheel</title>
|
||||||
|
<p><gui>Wheel</gui> zooms both axes · <gui>Shift</gui>+<gui>Wheel</gui> zooms x only ·
|
||||||
|
<gui>Ctrl</gui>+<gui>Wheel</gui> zooms y only.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Drag</title>
|
||||||
|
<p><gui>Drag</gui> pans · <gui>Shift</gui>+<gui>Drag</gui> is a box zoom on both axes ·
|
||||||
|
<gui>Ctrl</gui>+<gui>Drag</gui> on x only · <gui>Alt</gui>+<gui>Drag</gui> on values
|
||||||
|
only. The area being chosen is framed while you drag it, so you can see what the
|
||||||
|
release will apply.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Keyboard</title>
|
||||||
|
<p><key>+</key> and <key>-</key> zoom both axes; <key>Ctrl</key> with them restricts
|
||||||
|
to x, <key>Alt</key> to y. <gui>Arrow keys</gui> pan, <key>f</key> fits,
|
||||||
|
<key>Escape</key> cancels a gesture in progress.</p>
|
||||||
|
</item>
|
||||||
|
</terms>
|
||||||
|
|
||||||
|
<p>The full table is in the shortcut overview (<keyseq><key>Ctrl</key><key>/</key></keyseq>).</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>The keyboard zoom's modifiers are <em>not</em> the wheel's, and that is
|
||||||
|
deliberate: on common layouts <key>+</key> <em>is</em> <keyseq><key>Shift</key><key>=</key></keyseq>,
|
||||||
|
so a bare zoom-in already arrives with Shift set and Shift could not mean anything.
|
||||||
|
Ctrl/Alt mirror the box-zoom drags instead.</p></item>
|
||||||
|
<item><p>A <em>pinned</em> view is navigable within its own data but cannot leave it.
|
||||||
|
While an event is held, a capture is filling, or the view is paused, a zoom <em>in</em>
|
||||||
|
works normally — "not outside the event" is not "not readable" — but a pan or a zoom out
|
||||||
|
is clamped to the data.</p></item>
|
||||||
|
<item><p>Nothing you do on the plot changes the <em>time window</em> — only the header
|
||||||
|
control does. A zoom or a pan moves the view inside the window; <key>f</key> (and
|
||||||
|
<gui>Fit X axis</gui>) is the <em>undo</em> of a zoom, so a 2 s window zoomed into reads
|
||||||
|
2 s again. The window is more than the axis: it is also the trigger's event span and how
|
||||||
|
the samples are stored, so a wheel notch that redefined it would re-plan the buffer and
|
||||||
|
drop your history.</p></item>
|
||||||
|
<item><p>The axis does not scroll — the <em>data</em> does. A live axis is a fixed
|
||||||
|
<code>[-window .. 0]</code> with the newest sample at 0, so the labels stand still and
|
||||||
|
the trace rolls under them. The cursors stand still with the labels (they are positions
|
||||||
|
on screen, so a cursor you placed stays where you put it, over newer data each frame),
|
||||||
|
while a measurement's marks travel with the sample they measured and leave the view when
|
||||||
|
it does.</p></item>
|
||||||
|
<item><p>A live view is clamped to the window you asked for, not to what the ring
|
||||||
|
happens to hold. That is why a buffer that has only just started filling leaves its
|
||||||
|
left-hand side empty instead of squeezing the window down to the first few
|
||||||
|
samples.</p></item>
|
||||||
|
<item><p>Zooming hard into a held capture is exact. The renderer finds the visible
|
||||||
|
samples by binary search over the real timestamps, never over a nominal sample grid, so
|
||||||
|
a capture whose clock wandered does not drift away from its axis as you zoom.</p></item>
|
||||||
|
<item><p>A trace holding far more samples than the plot has pixels is drawn <em>one
|
||||||
|
pixel column at a time</em>: each column is a single vertical line from the lowest to
|
||||||
|
the highest value that falls in it. Drawn as a polyline instead, such a window puts
|
||||||
|
three or four near-vertical lines in every column at slightly different sub-pixel
|
||||||
|
positions, and how they overlap beats against the pixel grid — which reads as bands of
|
||||||
|
light and dark that <em>crawl</em> as you pan. One line per column removes the beat, and
|
||||||
|
it is also much faster: a 1 MS/s view costs a couple of thousand line segments a frame
|
||||||
|
instead of a couple of hundred thousand.</p></item>
|
||||||
|
<item><p>Those columns are <em>graded by brightness</em>, the way a digital-phosphor
|
||||||
|
scope reads: bright where the signal spent its time, faint where it only passed through.
|
||||||
|
A column's highest and lowest value are the same whether the samples in it are spread
|
||||||
|
evenly or piled at its two ends, so the grading is what tells the two apart — the flat
|
||||||
|
parts of a square wave come out solid and the edges between them faint. It is read from
|
||||||
|
a finer division of each column rather than from the line that is drawn, and it costs
|
||||||
|
about a millisecond a frame.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>Pause a live view, then press <keyseq><key>Ctrl</key><key>+</key></keyseq>: x narrows
|
||||||
|
and y does not move. Press <keyseq><key>Alt</key><key>+</key></keyseq>: y narrows and x
|
||||||
|
does not. Eight doublings into a held 2 s capture, the trace still reaches both margins —
|
||||||
|
the zoom probe reports <code>gap_px</code> and <code>end_px</code> within a pixel or two
|
||||||
|
at every step.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="overview">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#main"/>
|
||||||
|
<desc>What udpscope plots, the four parts of the window, and one pass through the
|
||||||
|
application from connecting to saving.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Overview</title>
|
||||||
|
|
||||||
|
<section id="what">
|
||||||
|
<title>What udpscope is</title>
|
||||||
|
<p>udpscope plots a live UDPStreamer source. One window holds up to eight rows of plots,
|
||||||
|
each with up to four traces, and every row shares one time axis, so the whole figure
|
||||||
|
scrolls together. Acquisition runs on its own thread and writes a ring buffer sized from
|
||||||
|
<cmd>--buffer-mb</cmd>; the drawing thread reads whatever that ring holds when a frame
|
||||||
|
starts, which is why a trace never makes the window stutter.</p>
|
||||||
|
|
||||||
|
<p>The stream is the clock. Every interval you read on screen is measured between
|
||||||
|
timestamps the source sent, never your machine's, so a stalled source freezes the view
|
||||||
|
instead of scrolling it — and a source whose clock steps still gets the window you asked
|
||||||
|
for.</p>
|
||||||
|
|
||||||
|
<p>The time axis is <em>relative to now</em>. While streaming it is a fixed
|
||||||
|
<code>[-window .. 0]</code> with the newest sample at 0, so x reads as an age:
|
||||||
|
<code>-1.5s</code> is a sample a second and a half old. In trigger mode 0 is the event's
|
||||||
|
edge instead, and a paused view keeps the ages it was frozen with. The source's own clock
|
||||||
|
is still there when you need it — an export writes it as a second column.</p>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<title>The udpscope window</title>
|
||||||
|
<desc>The header bar above the plots, the side panel on the right, and the status bar
|
||||||
|
along the bottom.</desc>
|
||||||
|
<media type="image" mime="image/png" src="figures/main-window.png" width="1280" height="800">
|
||||||
|
<p>One figure of three rows with four signals enabled, the Measure tab open in the
|
||||||
|
side panel, and the status bar reading <gui>live</gui>.</p>
|
||||||
|
</media>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<p>Start the app with a source
|
||||||
|
(<cmd>udpscope --server 127.0.0.1 --port 8000</cmd>), or start it with no
|
||||||
|
<cmd>--server</cmd> and pick one from the chooser, which lists the sessions you have
|
||||||
|
opened before. A unicast source serves one client at a time: if a second viewer connects,
|
||||||
|
they take turns and the status bar says so. To watch one source from several instances,
|
||||||
|
use <cmd>--multicast</cmd> with the group and <cmd>--interface</cmd> with the local
|
||||||
|
address.</p>
|
||||||
|
|
||||||
|
<p>The signals arrive <em>disabled</em>. Nothing is acquired until you turn one on in the
|
||||||
|
signal list, and enabling is what starts acquisition — so a freshly opened scope with an
|
||||||
|
idle side panel is working correctly, not failing to connect.</p>
|
||||||
|
|
||||||
|
<note style="tip">
|
||||||
|
<p>See <link xref="source">Choosing a source</link> for the chooser and the multicast
|
||||||
|
options in full.</p>
|
||||||
|
</note>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="window">
|
||||||
|
<title>The window</title>
|
||||||
|
<terms>
|
||||||
|
<item>
|
||||||
|
<title>The header bar</title>
|
||||||
|
<p>Left to right: the time window (<gui>2 s</gui>), the trigger and the pause, the
|
||||||
|
events, cursors and side-panel switches, and the menu, which holds
|
||||||
|
<gui>Fit to data</gui>, the <gui>Layout editor</gui>, <gui>Export</gui>,
|
||||||
|
<gui>Keyboard shortcuts</gui>, <gui>Help</gui> and <gui>About</gui>.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>The side panel</title>
|
||||||
|
<p>Two tabs. <gui>Measure</gui> holds the cursors readout and the measurements
|
||||||
|
derived from it; <gui>Signals</gui> holds the trigger and the channel list, where a
|
||||||
|
math channel is a row of that one list under a <gui>MATH</gui> heading whose
|
||||||
|
<gui>+</gui> creates one. One row per channel: its colour, an enable switch, its name
|
||||||
|
and its unit. Channels are dragged from here onto a plot row.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>The plot</title>
|
||||||
|
<p>Up to eight rows, each with its own y axis and up to four traces, all on one shared
|
||||||
|
time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>The status bar</title>
|
||||||
|
<p>Directly above the plot's bottom edge — the state word, the stored sample rate and
|
||||||
|
the window, and the ⓘ beside it for packets, gaps and the buffer's own figures. The
|
||||||
|
second screenshot below is what a held capture looks like: <gui>event held</gui>, the
|
||||||
|
axis pinned to the event, and the trigger marker at the edge.</p>
|
||||||
|
</item>
|
||||||
|
</terms>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<title>A held capture</title>
|
||||||
|
<desc>The trigger has latched, the state reads <gui>event held</gui>, and the axis is
|
||||||
|
pinned to the event rather than to the live edge.</desc>
|
||||||
|
<media type="image" mime="image/png" src="figures/trigger-capture.png" width="1280" height="800">
|
||||||
|
<p>The trigger marker sits at x = 0 and the pre-trigger share of the window is drawn
|
||||||
|
to its left.</p>
|
||||||
|
</media>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="first">
|
||||||
|
<title>The first five minutes</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p><em>Connect.</em> With no <cmd>--server</cmd>, pick a stored session or type a host
|
||||||
|
and port.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Enable a signal</em> in the signal list. The plot stays empty for the first
|
||||||
|
fraction of a second while the first window fills — the buffer holds only what has
|
||||||
|
arrived.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Drag it onto a row.</em> Drop a second signal on another row, or the same
|
||||||
|
signal again for a second view of it. The colour is per signal, so it is the same one
|
||||||
|
wherever it appears.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Set the window</em> with the control in the header bar: type a number, pick a
|
||||||
|
unit, or step a decade. The trigger's event window is a share of this span, so a
|
||||||
|
window change moves it.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Pause</em> (<key>space</key>) to look at a still picture; the producer keeps
|
||||||
|
filling the buffer behind it, and resuming shows what arrived meanwhile rather than a
|
||||||
|
hole.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Arm the trigger</em> (<key>a</key>) and wait for a latch: the view freezes on
|
||||||
|
one event, and you can zoom in on it. Disarming mid-capture keeps what the capture
|
||||||
|
got.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Measure it</em> — the measurements pane computes a statistic of the visible
|
||||||
|
window, and <gui>show on plot</gui> marks it as a ruler or a pair of markers.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><em>Save the data</em> with <gui>Export visible range</gui>, which writes the
|
||||||
|
plotted signals over the window at the stored rate to <file>.npy</file>,
|
||||||
|
<file>.csv</file> or <file>.mf3</file> (MDF 3.30).</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>A signal nobody enables costs nothing — no buffer, no socket work, no
|
||||||
|
measurements.</p></item>
|
||||||
|
<item><p>The window remembers itself. Rows, traces, the time window, calibration, which
|
||||||
|
signals were enabled, the math channels with their graphs, and the measurements are
|
||||||
|
written per host and port, and reloaded the next time you open that source.</p></item>
|
||||||
|
<item><p>A very wide window on a fast stream is drawn from averaged groups of samples,
|
||||||
|
so the rate in the status bar falls: that is the anti-alias filter, not a fault —
|
||||||
|
striding would fold a fast signal back as a slow one, which looks entirely plausible on
|
||||||
|
screen.</p></item>
|
||||||
|
<item><p>Every instrument the app computed — the trigger marker, the measurement marks
|
||||||
|
— is locked to the pointer's reach. The cursors are yours to drag; those are not.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="installing">
|
||||||
|
<title>Installing this manual</title>
|
||||||
|
<p>The manual is a help set the desktop viewer reads off disk, so <key>F1</key> opens it only
|
||||||
|
once a copy sits where <cmd>help:</cmd> resolves. Either of two targets installs it on its own,
|
||||||
|
without building the application, and each prints where it wrote. Prefer the system-wide one:
|
||||||
|
it is the copy every viewer can read, and the reason is worth knowing before you pick the
|
||||||
|
other:</p>
|
||||||
|
<list>
|
||||||
|
<item><p>For a system-wide or packaged install:
|
||||||
|
<cmd>cmake --build build --target install-help</cmd> writes it under the configured
|
||||||
|
<cmd>CMAKE_INSTALL_PREFIX</cmd>, so point that at <file>/usr</file>
|
||||||
|
(<cmd>cmake -DCMAKE_INSTALL_PREFIX=/usr ..</cmd>) and wrap the target in
|
||||||
|
<cmd>sudo</cmd>. A viewer reads its figures as separate files, and its sandbox exposes the
|
||||||
|
system help directories, so this copy always shows them.</p></item>
|
||||||
|
<item><p>For your own account, needing no root:
|
||||||
|
<cmd>cmake --build build --target install-help-user</cmd> writes it under
|
||||||
|
<file>$XDG_DATA_HOME</file> (default <file>~/.local/share</file>). Be aware that the same
|
||||||
|
sandbox does <em>not</em> expose the user's data directory: those pages open, but a viewer
|
||||||
|
such as Yelp shows them without their figures.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="pause">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#viewing"/>
|
||||||
|
<link type="seealso" xref="trigger"/>
|
||||||
|
<desc>Pausing freezes the display without stopping the stream, and the status bar says how
|
||||||
|
long you have been paused.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Pause and play</title>
|
||||||
|
|
||||||
|
<p>Pause freezes the <em>display</em>, not the acquisition. The producer keeps filling the
|
||||||
|
ring at full rate; the view binds an immutable snapshot of the window that was on screen,
|
||||||
|
and the axis is pinned to it.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Press the pause button in the toolbar, or <key>Space</key>.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Press it again to resume, which rebinds the live ring and puts the window back on
|
||||||
|
the newest data.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>Nothing is lost while paused. Because the producer never stops, resuming shows
|
||||||
|
current data with no hole in the history behind it.</p></item>
|
||||||
|
<item><p>The axis is pinned to the <em>snapshot's own stamps</em>, not to the window that
|
||||||
|
was requested. A window reaching back past the oldest retained sample yields a shorter
|
||||||
|
snapshot, and the pinned axis is that shorter span — so you see a narrower view rather
|
||||||
|
than an empty margin.</p></item>
|
||||||
|
<item><p>A paused view is pinned exactly as a held trigger event is: zoom in freely, but
|
||||||
|
a pan or a zoom out is clamped to the snapshot's data.</p></item>
|
||||||
|
<item><p>Pausing is the easiest way to measure a layout or read a cursor precisely,
|
||||||
|
because nothing under the pointer moves between frames.</p></item>
|
||||||
|
<item><p>The status bar says how long you have been paused — wall time, since the stream
|
||||||
|
itself never stopped. It is there so a frozen picture cannot be mistaken for a live one
|
||||||
|
that went quiet.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads
|
||||||
|
<code>[-1.40 .. 0.00]</code> rather than <code>[-2.00 .. 0.00]</code> with 0.6 s of blank.
|
||||||
|
The pause probe reports <code>ring_produced</code> still growing across the pause and
|
||||||
|
<code>event_size</code> non-zero exactly while paused.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="sessions">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#output"/>
|
||||||
|
<link type="seealso" xref="source"/>
|
||||||
|
<desc>Per-source configuration files: where they live, what they persist, and how the
|
||||||
|
chooser and the Configurations dialog use them.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Sessions and settings</title>
|
||||||
|
|
||||||
|
<p>udpscope stores named sessions — <em>configurations</em> — per source, so reopening a
|
||||||
|
source gives you the scope you closed, and one source can hold several arrangements for
|
||||||
|
different experiments. There is no global preferences file: the session <em>is</em> the
|
||||||
|
settings.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<p>Nothing to open — the file is written as you work. Its path is in the About dialog, and
|
||||||
|
the directory is <file>$XDG_CONFIG_HOME/udpscope</file>, or <file>~/.config/udpscope</file>
|
||||||
|
when that is unset. Each source has a directory <file><host>_<port>/</file>
|
||||||
|
holding one <file><name>.toml</file> per configuration and an <file>active</file>
|
||||||
|
file naming the one it is on.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="configs">
|
||||||
|
<title>Configurations</title>
|
||||||
|
<p><gui>Configurations…</gui> in the menu lists the running source's configurations: click
|
||||||
|
a row to open it, <gui>×</gui> to delete it, or type a name and a note and press
|
||||||
|
<gui>Save this session</gui> to keep the current arrangement under a new one. Opening a
|
||||||
|
configuration saves the current session first, so switching never loses what it switches
|
||||||
|
from.</p>
|
||||||
|
|
||||||
|
<p>The startup chooser (start with no <cmd>--server</cmd>) lists one row per configuration,
|
||||||
|
with its note and its settings, so two arrangements of one source are told apart before
|
||||||
|
they open.</p>
|
||||||
|
|
||||||
|
<p>A configuration also carries the settings that can only be decided when the buffers are
|
||||||
|
allocated: the <gui>buffer size</gui> (<cmd>--buffer-mb</cmd>), relative time, and the
|
||||||
|
packet capture's ring size. Opening a configuration from the chooser applies all three;
|
||||||
|
switching inside a running app applies the rest and says in the status line that these take
|
||||||
|
effect on the next start.</p>
|
||||||
|
|
||||||
|
<p>The chooser's form sets the first two for a new host, and its pencil button edits them
|
||||||
|
on an existing configuration — along with the name and the note. Renaming a configuration,
|
||||||
|
or editing the address it points at, moves it: the old file is removed rather than left
|
||||||
|
behind.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>What a configuration persists: the time window, the rows and each row's slot
|
||||||
|
signals, the calibration, the trace colours, which signals were enabled, the math
|
||||||
|
channels with their graphs, the measurements pane — and the startup settings: buffer
|
||||||
|
size, relative time and the packet capture's ring size.</p></item>
|
||||||
|
<item><p>Signal identity is the <em>name</em> throughout, because a new CONFIG renumbers
|
||||||
|
the channels.</p></item>
|
||||||
|
<item><p>A file written by a run against a source whose streamer was down would name no
|
||||||
|
signals at all, so such a run does <em>not</em> write: a configured session is never
|
||||||
|
overwritten by a run that configured nothing. (The chooser hides such a file for the same
|
||||||
|
reason.)</p></item>
|
||||||
|
<item><p>The multicast group and the interface live <em>inside</em> the file, because its
|
||||||
|
name cannot carry them. A file from an older build has neither key and loads as unicast
|
||||||
|
on the default route.</p></item>
|
||||||
|
<item><p>The chooser lists only configurations that name at least one signal, which is
|
||||||
|
what keeps it the sessions you configured rather than every port you have ever tested
|
||||||
|
against.</p></item>
|
||||||
|
<item><p>Deleting a configuration is a supported way to start over; the chooser's
|
||||||
|
<gui>×</gui> and the dialog's do exactly that.</p></item>
|
||||||
|
<item><p>A session from before configurations existed is imported once as the
|
||||||
|
configuration <code>default</code>, and the old file is kept beside the directory as
|
||||||
|
<file><host>_<port>.toml.imported</file>.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>Connect to <code>127.0.0.1:8000</code>, drop three signals on row 1, set a 2 s window,
|
||||||
|
and save the session as <code>bench</code> with a note.
|
||||||
|
<file>~/.config/udpscope/127.0.0.1_8000/bench.toml</file> now holds
|
||||||
|
<code>window_s=2 comment="…"</code> with
|
||||||
|
<code>signals = ["sine", "ramp", "noise", ""]</code>, and the next run reports
|
||||||
|
<code>layout loaded from …/127.0.0.1_8000/bench.toml</code> with those signals already
|
||||||
|
acquired.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="signals">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#main"/>
|
||||||
|
<link type="seealso" xref="layout"/>
|
||||||
|
<desc>How the source's CONFIG becomes channels, what enabling a signal starts, and how a
|
||||||
|
signal's scale and offset are calibrated.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Signals and calibration</title>
|
||||||
|
|
||||||
|
<p>The source's CONFIG message describes its signals: a name, a unit and a type. udpscope
|
||||||
|
turns each enabled signal into a <em>channel</em> — a ring of values beside the shared time
|
||||||
|
ring — and a channel is what a trace plots. Calibration is a scale and an offset applied at
|
||||||
|
ingest.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Open the side panel (<key>s</key>), which lists every signal with a switch.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The <gui>Signal list</gui> item in the overflow menu opens the editor where the
|
||||||
|
unit and the calibration live.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>Signals start disabled, and enabling one is what starts acquiring it. A trigger
|
||||||
|
on a signal nobody acquires can never fire, so arming a trigger enables its signal for
|
||||||
|
you.</p></item>
|
||||||
|
<item><p><gui>Calibrate</gui> in a signal's row menu opens the gain and offset it is
|
||||||
|
<em>currently</em> drawn through, so a trace already scaled by a gain of 2 shows 2 —
|
||||||
|
pressing <gui>Apply</gui> confirms it rather than resetting it. A signal nobody
|
||||||
|
calibrated shows the identity, 1 and 0.</p></item>
|
||||||
|
<item><p>Calibration is applied <em>at ingest</em>, so changing it re-plans the buffer
|
||||||
|
and drops the history. So does enabling or disabling a signal, and so does a new
|
||||||
|
CONFIG. An empty plot for a second after one of those is the ring refilling, not a
|
||||||
|
fault.</p></item>
|
||||||
|
<item><p>The stream's first signal is usually its <em>timestamp</em>, and that one is
|
||||||
|
never acquired. It cannot be plotted, measured or used as a math source, and udpscope
|
||||||
|
refuses to put it in a row rather than binding a trace that would read nothing for
|
||||||
|
ever.</p></item>
|
||||||
|
<item><p>A signal's colour follows the <em>signal</em>, not the slot, so the same signal
|
||||||
|
on two rows is one colour and the side panel's chip identifies it unambiguously. A
|
||||||
|
signal on no row gets a grey chip rather than none, so the panel's rows keep their
|
||||||
|
height.</p></item>
|
||||||
|
<item><p><em>Click a coloured chip to change that signal's colour.</em> A grey chip
|
||||||
|
names no trace, so it is not clickable. The colour is saved with the session;
|
||||||
|
<gui>Default colour</gui> in the signal's ⋯ menu gives the palette entry back.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>A source declaring <code>time</code>, <code>sine</code>, <code>ramp</code> and
|
||||||
|
<code>noise</code> yields three plottable signals. Enable all three and the exit dump
|
||||||
|
reads <code>3 signals, 3 channels</code>; <code>time</code> is not among them.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="source">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#main"/>
|
||||||
|
<link type="seealso" xref="sessions"/>
|
||||||
|
<desc>How udpscope is pointed at a source: the command line, the startup chooser, and
|
||||||
|
opening an exported waveform instead of a live stream.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Choosing a source</title>
|
||||||
|
|
||||||
|
<p>A source is a host and a port, optionally reached through a multicast group on a named
|
||||||
|
interface. Pass it on the command line, or let udpscope ask.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Run with <cmd>--server</cmd> and the app connects straight away.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Run <em>without</em> <cmd>--server</cmd> and a chooser appears first: one row per
|
||||||
|
stored <em>configuration</em> (<code>host:port · name</code>, its note, and the
|
||||||
|
settings it carries), newest first, plus a <gui>New host…</gui> form that takes the
|
||||||
|
address <em>and</em> the configuration it belongs to — its name and note, the
|
||||||
|
<gui>buffer size</gui>, and whether time is relative or absolute. A row's pencil
|
||||||
|
button opens the same form on that configuration, pre-filled, and changes to its name
|
||||||
|
or address move it (the old file is deleted).</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The second page, <gui>Waveforms</gui>, opens an <em>exported</em> run instead of a
|
||||||
|
live source: the files opened before (newest first; one that has since been deleted is
|
||||||
|
marked <em>missing</em>) and an <gui>Open…</gui> button for a new one. The same file
|
||||||
|
can be opened straight from the command line with <cmd>--open FILE</cmd>. A waveform
|
||||||
|
is a source that never arrives — no socket, no acquisition, no starvation notice — and
|
||||||
|
every instrument works on it: cursors, measurements, math channels and another export.
|
||||||
|
Only <file>.npy</file> files are read.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
|
||||||
|
<p>The chooser runs before any buffer is allocated and before any socket is opened, so
|
||||||
|
declining it costs nothing.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>The list is the sessions that <em>named a signal</em>. A session whose streamer
|
||||||
|
was down stores empty slot names and is filtered out, which is what keeps the list the
|
||||||
|
sources you actually configured rather than every port you have ever typed.</p></item>
|
||||||
|
<item><p>A stored row reconnects the way it was reached. The multicast group and the
|
||||||
|
interface are written inside the session file, because the file's name
|
||||||
|
(<file>host_port.toml</file>) cannot carry them.</p></item>
|
||||||
|
<item><p><cmd>--multicast</cmd> still needs <cmd>--server</cmd>: the CONFIG message is
|
||||||
|
fetched from the server, not from the group.</p></item>
|
||||||
|
<item><p>The <gui>×</gui> beside a row deletes that session file. Nothing else is
|
||||||
|
removed.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p><cmd>udpscope</cmd> with two stored sessions lists
|
||||||
|
<code>127.0.0.1:8000 plots=1 signals=3</code> and
|
||||||
|
<code>10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9</code>.
|
||||||
|
Activating the first reports <code>[sources] picked 127.0.0.1:8000</code> and continues
|
||||||
|
into the scope with that session's layout already loaded.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="status">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#output"/>
|
||||||
|
<desc>The status bar's two tiers, the activity badge, the fixed state words, and the ⓘ
|
||||||
|
detail popover.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Status bar and diagnostics</title>
|
||||||
|
|
||||||
|
<p>The status bar is two tiers. The bar itself is one glance line — the state, the rate and
|
||||||
|
the window — and everything else is behind the ⓘ button at its right-hand end.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<p>The bar is always visible under the plot. Click <gui>ⓘ</gui> for the detail popover.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>The badge is one glyph in four colours: green <em>alternating</em> lit and dim
|
||||||
|
means data is arriving, and a steady colour means it is not. A badge that never blinks is
|
||||||
|
not a working badge. Of the two steady colours, <em>grey</em> is no stream at all or a
|
||||||
|
pipeline you stopped (the tooltip says which), and <em>yellow</em> is a source that
|
||||||
|
answered but has sent no configuration.</p></item>
|
||||||
|
<item><p>The state field is one word from a fixed set: <code>connecting</code>,
|
||||||
|
<code>live</code>, <code>paused</code>, <code>armed</code>, <code>capturing</code>,
|
||||||
|
<code>event held</code>, <code>disarmed</code>. Two states add one field each: in trigger
|
||||||
|
mode, how long ago the last event was; while paused, how long you have been paused.</p></item>
|
||||||
|
<item><p><code>connecting</code> means there is no <em>signal list</em> yet — either no
|
||||||
|
session or a session with no configuration behind it. Every other word claims a signal
|
||||||
|
list, so until one arrives there is nothing for them to describe: nothing can be enabled
|
||||||
|
and no data is owed.</p></item>
|
||||||
|
<item><p>The paused figure is <em>wall</em> time, not stream time — the producer never
|
||||||
|
stops while paused, so it answers "how long have I been looking at this picture".</p></item>
|
||||||
|
<item><p>Nothing on the bar may change width with the stream, because the bar sits
|
||||||
|
directly above the plot and a wider label would relayout it. That is why the rate
|
||||||
|
collapses to one figure or a range rather than listing per channel, and why an elapsed
|
||||||
|
time steps to a coarser unit instead of growing digits.</p></item>
|
||||||
|
<item><p><code>gaps</code> counts packets the receiver knows it missed. It is the number
|
||||||
|
that matters: a non-zero count means samples are absent from the buffer, not merely
|
||||||
|
late.</p></item>
|
||||||
|
<item><p><code>reconnects</code> counts sessions re-established with the source. Over
|
||||||
|
unicast a climbing count usually means a second client is taking the source's single
|
||||||
|
session.</p></item>
|
||||||
|
<item><p>The starvation notice — <em>connected, but no data is arriving</em> — is that
|
||||||
|
same situation seen from the other side: the signal list is here, so something took the
|
||||||
|
stream away. A source that has sent <em>no</em> configuration gets a different notice,
|
||||||
|
<em>trying to connect</em>, because nothing was taken away — pointing the app at a port
|
||||||
|
where nothing answers is that case, and the usual cause is a streamer that is not running
|
||||||
|
or is serving another port.</p></item>
|
||||||
|
<item><p><code>stored</code> versus <code>wire</code> in the popover is the decimation.
|
||||||
|
They differ exactly when the window is too long to store at full rate.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>The bar reads <code>● live · 10 kS/s · 2 s window</code>. Behind ⓘ:
|
||||||
|
<code>Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3
|
||||||
|
channels</code>, <code>Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire
|
||||||
|
10 kS/s)</code>, <code>Plot 1 3 traces · 195000 samples</code>,
|
||||||
|
<code>Reconnects 0</code>.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="trigger">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#viewing"/>
|
||||||
|
<link type="seealso" xref="pause"/>
|
||||||
|
<desc>The trigger's four shapes, arming and hold-off, the event cache with its replay,
|
||||||
|
compare and accumulate, and searching the buffer for a condition.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Trigger</title>
|
||||||
|
|
||||||
|
<p>The trigger watches one signal for a condition and captures a window around it. The
|
||||||
|
<gui>Shape</gui> dropdown chooses what counts as an event; the arming, hold-off, pre/post
|
||||||
|
window and Normal/Single mode are the same for all four shapes. The captured window is
|
||||||
|
frozen on screen until you re-arm.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>The trigger button in the toolbar (<key>t</key>) reveals the trigger panel: the
|
||||||
|
signal, the level, the shape, the edge (rising, falling, either), the mode (Normal or
|
||||||
|
Single) and the pre-trigger share.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><gui>Arm</gui> starts watching; <key>a</key> arms and disarms from the keyboard.
|
||||||
|
When the pane is locked with its <gui>[edit]</gui> button — so that it states what the
|
||||||
|
trigger is set to instead of showing the controls — <gui>Arm/Disarm</gui> is in its
|
||||||
|
heading, and the trigger can be armed or disarmed without unfolding the panel. It is
|
||||||
|
offered only while trigger mode is on, because a trigger that is off has no engine to
|
||||||
|
arm.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p><gui>Search the buffer</gui> is at the foot of the same panel.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
|
||||||
|
<figure>
|
||||||
|
<title>A held capture</title>
|
||||||
|
<desc><gui>event held</gui> in the status bar, the axis pinned to the event, and the
|
||||||
|
trigger marker at x = 0.</desc>
|
||||||
|
<media type="image" mime="image/png" src="figures/trigger-capture.png" width="1280" height="800">
|
||||||
|
<p>The pre-trigger share is drawn to the left of the marker.</p>
|
||||||
|
</media>
|
||||||
|
</figure>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="shapes">
|
||||||
|
<title>The four shapes</title>
|
||||||
|
<terms>
|
||||||
|
<item>
|
||||||
|
<title>Edge</title>
|
||||||
|
<p>A crossing of the level in the chosen direction, the classic behaviour.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Pulse width</title>
|
||||||
|
<p>A crossing and its return, qualified by how long the excursion lasted: less than,
|
||||||
|
greater than, within or outside a pair of widths; the event is timed at the closing
|
||||||
|
edge.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Window</title>
|
||||||
|
<p>The signal entering or leaving a band between two levels; the panel keeps the two
|
||||||
|
levels at least one increment apart, because a zero-width band is not a band: the
|
||||||
|
condition becomes an exact float-equality test against the level, which an ordinary
|
||||||
|
signal never satisfies and a signal sitting at the level satisfies on every
|
||||||
|
transition.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<title>Dropout</title>
|
||||||
|
<p>The absence of a crossing for longer than a timeout; one event per outage, re-arms
|
||||||
|
when a crossing returns.</p>
|
||||||
|
</item>
|
||||||
|
</terms>
|
||||||
|
|
||||||
|
<p>The shape's own rows are the only ones shown; the rest are hidden, so the panel never
|
||||||
|
offers a pulse width to a window trigger.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="cache">
|
||||||
|
<title>The event cache, replay and compare</title>
|
||||||
|
<p><em>Every triggered acquisition is kept.</em> Latches are written to the event cache
|
||||||
|
(<file>~/.cache/udpscope/events/<source>/</file>) as a <file>.npy</file> of the
|
||||||
|
event, a <file>.txt</file> of what it was, and a <file>.bmp</file> picture drawn from the
|
||||||
|
samples. The <gui>Events</gui> pane on the left — opened from the toolbar's
|
||||||
|
event-history button, which appears while recording is enabled, and collapsed until you
|
||||||
|
ask for it — lists the last 32 newest first (time, duration, signals, rate), each row with
|
||||||
|
its own picture beside the numbers.</p>
|
||||||
|
|
||||||
|
<p>Clicking a row <em>replays</em> it: the stored event is loaded exactly like an exported
|
||||||
|
file, <em>without changing the layout</em> — the rows, the colours, the window, the
|
||||||
|
cursors and the measurements stay where they are and read the stored data instead. A
|
||||||
|
replay leaves trigger mode, because a file has no edge to wait for, and the pane stays
|
||||||
|
open. Drag the pane's edge to resize it. <gui>Live</gui> goes back to the stream; the
|
||||||
|
status bar names the file until you do.</p>
|
||||||
|
|
||||||
|
<p>Select two or more events in the list and the two buttons under the header act on them:
|
||||||
|
<gui>Compare</gui> puts two events in two plots side by side, keeping the layout you have
|
||||||
|
— one plot per event, the axes synchronised, so zooming a row in one zooms the same row in
|
||||||
|
the other — and <gui>Accumulate</gui> overlays up to four of them in the same traces,
|
||||||
|
translucent, so where they agree the trace is brighter. Your own traces stay where they
|
||||||
|
are in both. They need events with the same signals and the same length. A single click
|
||||||
|
selects, a <em>double</em> click replays.</p>
|
||||||
|
|
||||||
|
<p><gui>Record events</gui>, in the trigger panel with the trigger's other options, is
|
||||||
|
what decides whether anything is kept. It is <em>off by default</em> — nothing is written
|
||||||
|
to disk until you ask — and with it off the history button goes with it.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="search">
|
||||||
|
<title>Search the buffer</title>
|
||||||
|
<p><gui>Search the buffer</gui>, at the foot of the trigger panel and collapsed until you open
|
||||||
|
it, answers the question the trigger answers — <em>where does this condition hold?</em> — over
|
||||||
|
the <em>whole</em> retained buffer rather than at the live edge. It runs the trigger's own
|
||||||
|
evaluator over the ring in stream order, so a <gui>hit</gui> is not a level you point at: it is
|
||||||
|
an <em>instant</em> the trigger would have fired on, timed the way an acquisition would have
|
||||||
|
timed it. A pulse-width finding, for instance, is the closing edge of a qualifying excursion,
|
||||||
|
not the excursion itself.</p>
|
||||||
|
|
||||||
|
<p>The condition is the one the rest of the panel is set to — the same signal, shape, level and
|
||||||
|
options — so there is no second condition to keep in step: change the panel and the next search
|
||||||
|
asks the new question. Nothing is searched until you ask, and the status line says
|
||||||
|
<gui>not searched</gui>.</p>
|
||||||
|
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>Set the condition above, then press <gui>Search</gui>. Covering the whole ring takes a
|
||||||
|
moment, and the scan runs in chunks so the stream keeps arriving while it does; the status
|
||||||
|
line counts the ground covered — <gui>scanning 1.2 s of 8.0 s, 40 found</gui>.
|
||||||
|
<gui>Stop</gui> ends a scan early and keeps the hits found so far.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Step through the hits with the arrows either side of the position readout
|
||||||
|
(<gui>3 of 40</gui>). The first <gui>next</gui> goes to the first hit, and the ends do not
|
||||||
|
wrap — a wrap from the last hit to the first would look exactly like the view not having
|
||||||
|
moved. Each step <em>pauses</em> on its hit, centred, exactly as the Pause button does, so
|
||||||
|
what you read is the frozen window cut around that instant; press the pause button again to
|
||||||
|
go back to the stream.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>Every plot row marks the hits that fall in the window, up to sixteen of them. When more
|
||||||
|
are in view the pane says so and suggests zooming in — <gui>16 of 38 marks shown — zoom in
|
||||||
|
to see the rest</gui> — and when the selected hit has scrolled off the window it says that
|
||||||
|
instead. A hit the live stream has since overwritten reads <gui>no longer in the buffer</gui>
|
||||||
|
beside its position.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
|
||||||
|
<p>How a scan ended is part of the answer: <gui>N found in 8.0 s</gui> when it covered the whole
|
||||||
|
ring; <gui>N found; the stream overtook the oldest samples</gui> when the producer overwrote
|
||||||
|
samples before the scan reached them, so the count is of what was left; and
|
||||||
|
<gui>N found (the limit) in the newest …</gui> when a condition that holds almost everywhere —
|
||||||
|
an edge on a noisy signal — reached the ten-thousand-hit ceiling. The last is a stop, not a
|
||||||
|
crash: a hit list nobody could navigate is not worth building.</p>
|
||||||
|
|
||||||
|
<p>A result describes the ring it was taken from, so it is <em>dropped</em> when that ring is
|
||||||
|
replaced: a new <gui>CONFIG</gui> from the source, or a change to the signal set, re-describes
|
||||||
|
the stream and clears the hits. The condition above is untouched — only the answer to it
|
||||||
|
is.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>Arming <em>enables</em> the signal if it was off, because a trigger on a signal
|
||||||
|
nobody acquires can never fire.</p></item>
|
||||||
|
<item><p>The pre-trigger share is a share of the <em>display window</em>, not of the
|
||||||
|
ring. A 2 s window at 50% captures 1 s before the edge and 1 s after.</p></item>
|
||||||
|
<item><p>There are four display states, and the status bar names each: <gui>armed</gui>
|
||||||
|
(watching, nothing drawn yet), <gui>capturing</gui> (the edge was accepted and the post
|
||||||
|
window is filling <em>in view</em>, with the marker at x = 0), <gui>event held</gui>
|
||||||
|
(the frozen capture) and <gui>disarmed</gui>. A held capture is never quietly replaced
|
||||||
|
by live data.</p></item>
|
||||||
|
<item><p>The first edge after arming is only accepted once the history reaches back a
|
||||||
|
full pre-trigger window, so a capture may legitimately be shorter than you asked for.
|
||||||
|
The axis is pinned to what the capture actually got.</p></item>
|
||||||
|
<item><p>Disarming <em>during</em> a capture publishes what the capture got to, so the
|
||||||
|
picture on screen at that moment stays on screen. No event fired, so the event count
|
||||||
|
does not move.</p></item>
|
||||||
|
<item><p>The trigger reads the <em>stored</em> signal — decimated and averaged — not the
|
||||||
|
wire signal. A spike narrower than one decimation group is filtered away rather than
|
||||||
|
triggering on data the buffer does not contain.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans
|
||||||
|
<code>[-1.000 .. 1.000]</code> with <code>n=20001</code> samples at 10 kS/s — the span
|
||||||
|
times the real rate, plus one — and the marker sits at x = 0 throughout.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<page xmlns="http://projectmallard.org/1.0/"
|
||||||
|
type="topic" style="task"
|
||||||
|
id="window">
|
||||||
|
|
||||||
|
<info>
|
||||||
|
<link type="guide" xref="index#viewing"/>
|
||||||
|
<link type="seealso" xref="navigate"/>
|
||||||
|
<desc>The time window control: how to set it, and why a long window is decimated rather
|
||||||
|
than refused.</desc>
|
||||||
|
<credit type="author copyright">
|
||||||
|
<name>Martino Ferrari</name>
|
||||||
|
</credit>
|
||||||
|
</info>
|
||||||
|
|
||||||
|
<title>Time window</title>
|
||||||
|
|
||||||
|
<p>The window is how much time the plot shows. Every row shares one window: the figure has
|
||||||
|
a single x link group, so the rows cannot drift apart. The trigger's event span follows it,
|
||||||
|
and the pre-trigger share is a share of <em>this</em> window.</p>
|
||||||
|
|
||||||
|
<section id="reach">
|
||||||
|
<title>How to reach it</title>
|
||||||
|
<steps>
|
||||||
|
<item>
|
||||||
|
<p>The header bar's window button opens a popover: type a number, pick a unit under
|
||||||
|
it, or step a decade with the arrows.</p>
|
||||||
|
</item>
|
||||||
|
<item>
|
||||||
|
<p>The button's own label is a reading of the current window in a natural unit,
|
||||||
|
width-locked so a change of window cannot widen the header bar.</p>
|
||||||
|
</item>
|
||||||
|
</steps>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="knowing">
|
||||||
|
<title>Worth knowing</title>
|
||||||
|
<list>
|
||||||
|
<item><p>A window too long to store at the wire rate is <em>decimated, not refused</em>.
|
||||||
|
The shared clock takes a power-of-two stride, and each group of samples is
|
||||||
|
<em>averaged</em> rather than strided, so a tone above the stored Nyquist is attenuated
|
||||||
|
instead of aliasing to a false low frequency.</p></item>
|
||||||
|
<item><p>The stamp stored for a group is the group's <em>mean</em>, which is where the
|
||||||
|
averaging puts the value. Stamping at the end of a group would shift every trace half a
|
||||||
|
stored sample late.</p></item>
|
||||||
|
<item><p>Moving the window across a decimation boundary rebuilds the buffer and drops
|
||||||
|
the history. Walking it inside one decade is free.</p></item>
|
||||||
|
<item><p><em>This control is the only thing that changes the window.</em> A zoom, a pan
|
||||||
|
or a fit moves the view <em>inside</em> it and leaves it alone — which is the point of
|
||||||
|
the previous bullet: a wheel notch that redefined the window could re-plan the buffer
|
||||||
|
and throw your samples away.</p></item>
|
||||||
|
<item><p>The trigger sees the <em>stored</em> (filtered) signal, not the wire signal,
|
||||||
|
because an edge found on a sample that is not in the buffer could not be cut out of
|
||||||
|
it.</p></item>
|
||||||
|
</list>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="example">
|
||||||
|
<title>Example</title>
|
||||||
|
<p>1 MS/s with 200 MB of buffer. A 2 s window stores every sample
|
||||||
|
(<code>time x1</code>). Widen it to 600 s and the stride becomes <code>x16</code>: 625 S/s
|
||||||
|
stored, anti-aliased at 312.5 Hz. A 2 kHz sine seen through that window reads
|
||||||
|
<code>y=[-0.059, 0.079]</code> — attenuated to about 6%, not folded down to a
|
||||||
|
full-amplitude 125 Hz one.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</page>
|
||||||
+18
-2
@@ -3,6 +3,8 @@
|
|||||||
|
|
||||||
#include "acquisition.hpp"
|
#include "acquisition.hpp"
|
||||||
|
|
||||||
|
#include <limits>
|
||||||
|
|
||||||
#include <udps_client.h>
|
#include <udps_client.h>
|
||||||
|
|
||||||
#include "layout_store.hpp"
|
#include "layout_store.hpp"
|
||||||
@@ -50,6 +52,12 @@ Acquisition::Acquisition(ScopeModel& model, const Config& cfg) : model_(model),
|
|||||||
Acquisition::~Acquisition() { stop(); }
|
Acquisition::~Acquisition() { stop(); }
|
||||||
|
|
||||||
void Acquisition::start() {
|
void Acquisition::start() {
|
||||||
|
// A started -- or *restarted* -- client is a new stream. Going live from a file view is the restart that
|
||||||
|
// matters: the store still holds the event's samples, and the new stream's clock can sit behind their
|
||||||
|
// stamps, so the guard below has to re-base rather than clamp. `ring=[2.5200..2.5200]` with `n` climbing
|
||||||
|
// was the symptom -- every packet pinned flat at the file view's last stamp.
|
||||||
|
model_.reset_stream();
|
||||||
|
last_stamp_ = -std::numeric_limits<double>::infinity();
|
||||||
udps_client_config_t c;
|
udps_client_config_t c;
|
||||||
udps_client_config_init(&c);
|
udps_client_config_init(&c);
|
||||||
c.server_addr = cfg_.server.c_str();
|
c.server_addr = cfg_.server.c_str();
|
||||||
@@ -917,12 +925,20 @@ void Acquisition::on_data(const udps_frame_t* frame) {
|
|||||||
std::fprintf(stderr,
|
std::fprintf(stderr,
|
||||||
"[udpscope] trig engine: ch=%d armed=%d pending=%d elapsed=%.4f/%.4f s "
|
"[udpscope] trig engine: ch=%d armed=%d pending=%d elapsed=%.4f/%.4f s "
|
||||||
"accum=%llu history=%.4f/%.4f s pre_pct=%.0f v=%.4g now_t=%.3f "
|
"accum=%llu history=%.4f/%.4f s pre_pct=%.0f v=%.4g now_t=%.3f "
|
||||||
"holdoff_until=%.3f\n",
|
"holdoff_until=%.3f ring=[%.4f..%.4f] n=%zu\n",
|
||||||
trig_channel, int(engine.armed()), int(engine.pending()),
|
trig_channel, int(engine.armed()), int(engine.pending()),
|
||||||
engine.post_elapsed(), engine.post_seconds(),
|
engine.post_elapsed(), engine.post_seconds(),
|
||||||
static_cast<unsigned long long>(engine.post_accum()), engine.history(),
|
static_cast<unsigned long long>(engine.post_accum()), engine.history(),
|
||||||
engine.pre_seconds(), tc.pretrigger_pct, engine.last_value(),
|
engine.pre_seconds(), tc.pretrigger_pct, engine.last_value(),
|
||||||
engine.last_t(), engine.holdoff_until());
|
engine.last_t(), engine.holdoff_until(),
|
||||||
|
// The store's own retained span, beside the engine's view of the clock. Going live
|
||||||
|
// restarts the client, and the question a stalled display raises is whether the *time
|
||||||
|
// ring* is moving at all: `n` climbing while `ring` stands still is a clock problem, and
|
||||||
|
// `ring` moving while `now_t` does not is a feed problem. Neither can be told from the
|
||||||
|
// engine's own fields.
|
||||||
|
store.time().size() > 0 ? double(store.time()[0]) : 0.0,
|
||||||
|
store.time().size() > 0 ? double(store.time()[store.time().size() - 1]) : 0.0,
|
||||||
|
store.time().size());
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,13 @@ struct Config {
|
|||||||
/// live source. File mode and source mode are exclusive -- there is no acquisition behind a file,
|
/// live source. File mode and source mode are exclusive -- there is no acquisition behind a file,
|
||||||
/// and the chooser's Waveforms page sets this rather than the connection fields.
|
/// and the chooser's Waveforms page sets this rather than the connection fields.
|
||||||
std::string open_waveform;
|
std::string open_waveform;
|
||||||
|
|
||||||
|
/// The source chooser's "Guided tour" entry. The tour is drawn on the application's own window,
|
||||||
|
/// which does not exist while the chooser runs, so the request rides on the one value `main()`
|
||||||
|
/// owns and hands to both the chooser and the front end (`open_waveform` and the preferences'
|
||||||
|
/// relative time cross the same way) and the window starts the tour once it is up and laid out.
|
||||||
|
/// It is *not* a preference: it describes this request and nothing else, so nothing persists it.
|
||||||
|
bool tour_pending = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
|
/// Parses argv. On `--help` prints usage to stdout and exits(0); on a bad
|
||||||
|
|||||||
+354
-12
@@ -6,8 +6,11 @@
|
|||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <bit>
|
#include <bit>
|
||||||
#include <cerrno>
|
#include <cerrno>
|
||||||
|
#include <cstdint>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstring>
|
#include <cstring>
|
||||||
|
#include <ctime>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
namespace {
|
namespace {
|
||||||
@@ -34,18 +37,29 @@ std::string sanitise(const std::string& name) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The final labels: sanitised, and de-duplicated against each other *and* against the
|
/// `base` carrying a `_2`, `_3` … suffix for the `n`-th copy (`n == 1` is the name itself),
|
||||||
/// two time columns, with a `_2`, `_3` suffix. Two elements of one vector signal can
|
/// cut to `max_len` bytes when one is given -- MDF's name field is 32 bytes wide and the file
|
||||||
/// easily sanitise to the same string.
|
/// is re-read by a reader that keeps only the *first* occurrence of a name, so a cut that made
|
||||||
std::vector<std::string> column_labels(const ExportTable& table) {
|
/// two channels equal would silently lose one.
|
||||||
std::vector<std::string> taken{"time", "abs_seconds"};
|
std::string numbered(const std::string& base, int n, std::size_t max_len) {
|
||||||
|
const std::string suffix = n == 1 ? std::string() : "_" + std::to_string(n);
|
||||||
|
std::size_t room = base.size();
|
||||||
|
if (max_len != 0) room = max_len > suffix.size() ? max_len - suffix.size() : 0;
|
||||||
|
return base.substr(0, room) + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The final labels: sanitised, and de-duplicated against each other *and* against
|
||||||
|
/// `taken` -- the names the writer already spends -- with a `_2`, `_3` suffix. Two
|
||||||
|
/// elements of one vector signal can easily sanitise to the same string.
|
||||||
|
std::vector<std::string> column_labels(const ExportTable& table, std::vector<std::string> taken,
|
||||||
|
std::size_t max_len = 0) {
|
||||||
std::vector<std::string> out;
|
std::vector<std::string> out;
|
||||||
out.reserve(table.columns.size());
|
out.reserve(table.columns.size());
|
||||||
for (const ExportTable::Column& col : table.columns) {
|
for (const ExportTable::Column& col : table.columns) {
|
||||||
const std::string base = sanitise(col.name);
|
const std::string base = sanitise(col.name);
|
||||||
std::string label = base;
|
std::string label = numbered(base, 1, max_len);
|
||||||
for (int n = 2; std::find(taken.begin(), taken.end(), label) != taken.end(); ++n) {
|
for (int n = 2; std::find(taken.begin(), taken.end(), label) != taken.end(); ++n) {
|
||||||
label = base + "_" + std::to_string(n);
|
label = numbered(base, n, max_len);
|
||||||
}
|
}
|
||||||
taken.push_back(label);
|
taken.push_back(label);
|
||||||
out.push_back(std::move(label));
|
out.push_back(std::move(label));
|
||||||
@@ -69,6 +83,74 @@ bool opened(const File& out, const std::string& path, std::string& error) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The block lengths of MDF 3.30, from the **reader's** own field lists
|
||||||
|
/// (`asammdf/blocks/v2_v3_constants.py`, 8.8.27): every block is a two-letter id, a 16-bit
|
||||||
|
/// length and then its own fields, and the reader walks the file by those lengths alone, so a
|
||||||
|
/// block written one field too wide is a file whose reader lands in the middle of the next one.
|
||||||
|
/// The widths here are the ones a `bytes(block)` of the reference file measures (the known-good
|
||||||
|
/// 3.30 file `/tmp/ref.mdf` this writer was byte-diffed against).
|
||||||
|
constexpr std::size_t kMdfIdBlock = 64; // "MDF 3.30" and the flags, no id/length
|
||||||
|
constexpr std::size_t kMdfHeader = 208; // 164 common + 44 of the 3.2-era time fields
|
||||||
|
constexpr std::size_t kMdfTxOverhead = 5; // "TX", the u16 length and the terminating NUL
|
||||||
|
constexpr std::size_t kMdfDataGroup = 28; // 4 addresses, the group count, 4 reserved
|
||||||
|
constexpr std::size_t kMdfConversion = 46; // a NONE conversion is the common fields alone
|
||||||
|
constexpr std::size_t kMdfChannel = 228; // the display-name-length CNBLOCK
|
||||||
|
constexpr std::size_t kMdfChannelGroup = 30; // 26 + the sample-reduction address of 3.30
|
||||||
|
constexpr std::size_t kMdfNameBytes = 31; // a CNBLOCK's 32-byte name field, less its NUL
|
||||||
|
|
||||||
|
/// A byte sink for the MDF header blocks, which are a few hundred bytes each and have to be
|
||||||
|
/// laid out field by field. The little-endian `raw()` writes are the same assumption the `.npy`
|
||||||
|
/// writer states at the top of this file, and `static_assert`s there.
|
||||||
|
struct Bytes {
|
||||||
|
std::vector<unsigned char> v;
|
||||||
|
|
||||||
|
void raw(const void* p, std::size_t n) {
|
||||||
|
const auto* b = static_cast<const unsigned char*>(p);
|
||||||
|
v.insert(v.end(), b, b + n);
|
||||||
|
}
|
||||||
|
void u16(std::uint16_t x) {
|
||||||
|
const unsigned char b[2] = {static_cast<unsigned char>(x & 0xFFu),
|
||||||
|
static_cast<unsigned char>((x >> 8) & 0xFFu)};
|
||||||
|
raw(b, sizeof(b));
|
||||||
|
}
|
||||||
|
void i16(std::int16_t x) { u16(static_cast<std::uint16_t>(x)); }
|
||||||
|
void u32(std::uint32_t x) {
|
||||||
|
const unsigned char b[4] = {static_cast<unsigned char>(x & 0xFFu),
|
||||||
|
static_cast<unsigned char>((x >> 8) & 0xFFu),
|
||||||
|
static_cast<unsigned char>((x >> 16) & 0xFFu),
|
||||||
|
static_cast<unsigned char>((x >> 24) & 0xFFu)};
|
||||||
|
raw(b, sizeof(b));
|
||||||
|
}
|
||||||
|
void u64(std::uint64_t x) {
|
||||||
|
u32(static_cast<std::uint32_t>(x & 0xFFFFFFFFu));
|
||||||
|
u32(static_cast<std::uint32_t>(x >> 32));
|
||||||
|
}
|
||||||
|
void f64(double x) { raw(&x, sizeof(x)); }
|
||||||
|
void f32(float x) { raw(&x, sizeof(x)); }
|
||||||
|
void zeros(std::size_t n) { v.insert(v.end(), n, 0); }
|
||||||
|
|
||||||
|
/// A fixed-width text field: the bytes, then NUL padding. MDF's names and units are
|
||||||
|
/// NUL-terminated inside a field of a width the reader is told by the block size, so this is
|
||||||
|
/// how every string in the format is spelled.
|
||||||
|
void text(const std::string& s, std::size_t width) {
|
||||||
|
const std::size_t n = std::min(s.size(), width);
|
||||||
|
raw(s.data(), n);
|
||||||
|
zeros(width - n);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t size() const noexcept { return v.size(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Checked rather than trusted: a field written at the wrong width would not fail here, it
|
||||||
|
/// would produce a file whose reader walks into the middle of the next block. The widths are
|
||||||
|
/// the asammdf 8.8.27 reader's own field lists (`blocks/v2_v3_constants.py`).
|
||||||
|
bool mdf_block_size(const Bytes& block, std::size_t want, const char* what, std::string& error) {
|
||||||
|
if (block.size() == want) return true;
|
||||||
|
error = std::string("internal: the MDF ") + what + " block is " + std::to_string(block.size()) +
|
||||||
|
" bytes, the layout wants " + std::to_string(want);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
||||||
@@ -97,6 +179,12 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
return static_cast<std::size_t>(c) < infos.size() ? infos[static_cast<std::size_t>(c)].name
|
return static_cast<std::size_t>(c) < infos.size() ? infos[static_cast<std::size_t>(c)].name
|
||||||
: std::string("ch") + std::to_string(c);
|
: std::string("ch") + std::to_string(c);
|
||||||
};
|
};
|
||||||
|
// The unit travels with the column so the MDF writer can put it on the channel; the other
|
||||||
|
// writers ignore it, as they always have.
|
||||||
|
const auto unit_of = [&](int c) {
|
||||||
|
return static_cast<std::size_t>(c) < infos.size() ? infos[static_cast<std::size_t>(c)].unit
|
||||||
|
: std::string();
|
||||||
|
};
|
||||||
|
|
||||||
// What the rows bind to is what gets exported, so the split is the same `DisplayMode`
|
// What the rows bind to is what gets exported, so the split is the same `DisplayMode`
|
||||||
// `RowSet::bind()` switches on: the frozen event only while one is held, the live ring
|
// `RowSet::bind()` switches on: the frozen event only while one is held, the live ring
|
||||||
@@ -136,8 +224,9 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
if (ch >= snap->values.size()) continue;
|
if (ch >= snap->values.size()) continue;
|
||||||
const std::vector<f32>& src = snap->values[ch];
|
const std::vector<f32>& src = snap->values[ch];
|
||||||
if (src.size() < first + n) continue;
|
if (src.size() < first + n) continue;
|
||||||
table.columns.push_back({name_of(c), std::vector<f32>(src.begin() + static_cast<std::ptrdiff_t>(first),
|
table.columns.push_back({name_of(c), unit_of(c),
|
||||||
src.begin() + static_cast<std::ptrdiff_t>(first + n))});
|
std::vector<f32>(src.begin() + static_cast<std::ptrdiff_t>(first),
|
||||||
|
src.begin() + static_cast<std::ptrdiff_t>(first + n))});
|
||||||
}
|
}
|
||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
@@ -177,7 +266,7 @@ ExportTable collect_export(ScopeModel& model, RowSet& rows) {
|
|||||||
if (ch >= model.channel_count()) continue;
|
if (ch >= model.channel_count()) continue;
|
||||||
std::vector<f32> values(static_cast<std::size_t>(n));
|
std::vector<f32> values(static_cast<std::size_t>(n));
|
||||||
store.values(ch).copy_from(first, n, values.data());
|
store.values(ch).copy_from(first, n, values.data());
|
||||||
table.columns.push_back({name_of(c), std::move(values)});
|
table.columns.push_back({name_of(c), unit_of(c), std::move(values)});
|
||||||
}
|
}
|
||||||
return table;
|
return table;
|
||||||
}
|
}
|
||||||
@@ -191,6 +280,7 @@ ExportTable event_table(ScopeModel& model, const EventSnapshot& snapshot) {
|
|||||||
for (std::size_t c = 0; c < snapshot.values.size() && c < channels.size(); ++c) {
|
for (std::size_t c = 0; c < snapshot.values.size() && c < channels.size(); ++c) {
|
||||||
ExportTable::Column column;
|
ExportTable::Column column;
|
||||||
column.name = channels[c].name;
|
column.name = channels[c].name;
|
||||||
|
column.unit = channels[c].unit;
|
||||||
column.values = snapshot.values[c];
|
column.values = snapshot.values[c];
|
||||||
table.columns.push_back(std::move(column));
|
table.columns.push_back(std::move(column));
|
||||||
}
|
}
|
||||||
@@ -202,7 +292,7 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
error = "nothing to export: no signal is plotted in the visible range";
|
error = "nothing to export: no signal is plotted in the visible range";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const std::vector<std::string> labels = column_labels(table);
|
const std::vector<std::string> labels = column_labels(table, {"time", "abs_seconds"});
|
||||||
|
|
||||||
// `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy
|
// `descr` as a list of (name, format) pairs is a *packed* structured dtype -- NumPy
|
||||||
// only inserts alignment padding when asked to (`align=True`) -- so the records below
|
// only inserts alignment padding when asked to (`align=True`) -- so the records below
|
||||||
@@ -266,7 +356,7 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
File out(path);
|
File out(path);
|
||||||
if (!opened(out, path, error)) return false;
|
if (!opened(out, path, error)) return false;
|
||||||
|
|
||||||
const std::vector<std::string> labels = column_labels(table);
|
const std::vector<std::string> labels = column_labels(table, {"time", "abs_seconds"});
|
||||||
std::string line = "time,abs_seconds";
|
std::string line = "time,abs_seconds";
|
||||||
for (const std::string& label : labels) line += "," + label;
|
for (const std::string& label : labels) line += "," + label;
|
||||||
line += "\n";
|
line += "\n";
|
||||||
@@ -302,4 +392,256 @@ bool write_csv(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool write_mdf(const std::string& path, const ExportTable& table, std::string& error) {
|
||||||
|
if (table.empty()) {
|
||||||
|
error = "nothing to export: no signal is plotted in the visible range";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const std::size_t rows = table.rows();
|
||||||
|
const std::size_t cols = table.columns.size();
|
||||||
|
|
||||||
|
// **The master is the absolute clock, named `time`**, and the axis the plot showed goes out as
|
||||||
|
// an ordinary channel, `time_relative`. The relative column is 0 at a different sample in each
|
||||||
|
// display mode, so a master off it would put a live export and a held event's export on two
|
||||||
|
// timelines -- the one thing the second column exists to avoid -- while `abs_seconds` is the
|
||||||
|
// number the stream itself stamped the samples with. Both carry the unit `s`.
|
||||||
|
const std::vector<std::string> labels =
|
||||||
|
column_labels(table, {"time", "time_relative"}, kMdfNameBytes);
|
||||||
|
const std::size_t channels = cols + 2;
|
||||||
|
const std::size_t record = 2 * sizeof(f64) + cols * sizeof(f32);
|
||||||
|
if (rows > 0xFFFFFFFFull) {
|
||||||
|
error = "too many samples for an MDF 3 file: the channel group counts cycles in 32 bits";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (128 + (cols - 1) * 32 > 0xFFFFu) {
|
||||||
|
error = "too many signals for an MDF 3 record: a channel's start offset is 16 bits";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One channel per column, described once: what the CNBLOCK says about it (where in the record
|
||||||
|
// its samples start, how wide they are, whether it is the master) and what the CCBLOCK says
|
||||||
|
// (its unit). The range is measured from the samples rather than left at zero, so
|
||||||
|
// `range_flag = 1` means what it says.
|
||||||
|
struct Channel {
|
||||||
|
std::string name;
|
||||||
|
std::string unit;
|
||||||
|
std::uint16_t bit_offset;
|
||||||
|
std::uint16_t bit_count;
|
||||||
|
std::uint16_t data_type; // MDF 3: 2 = float, 3 = double, both in the ID block's byte order
|
||||||
|
std::uint16_t type; // 0 = value, 1 = master
|
||||||
|
f64 lo, hi;
|
||||||
|
};
|
||||||
|
const auto range_of = [](const auto& values) {
|
||||||
|
if (values.empty()) return std::pair<f64, f64>{0.0, 0.0};
|
||||||
|
const auto mm = std::minmax_element(values.begin(), values.end());
|
||||||
|
return std::pair<f64, f64>{static_cast<f64>(*mm.first), static_cast<f64>(*mm.second)};
|
||||||
|
};
|
||||||
|
std::vector<Channel> chans;
|
||||||
|
chans.reserve(channels);
|
||||||
|
const auto abs_range = range_of(table.abs_seconds);
|
||||||
|
const auto rel_range = range_of(table.time);
|
||||||
|
chans.push_back({"time", "s", 0, 64, 3, 1, abs_range.first, abs_range.second});
|
||||||
|
chans.push_back({"time_relative", "s", 64, 64, 3, 0, rel_range.first, rel_range.second});
|
||||||
|
for (std::size_t i = 0; i < cols; ++i) {
|
||||||
|
const auto range = range_of(table.columns[i].values);
|
||||||
|
chans.push_back({labels[i], table.columns[i].unit,
|
||||||
|
static_cast<std::uint16_t>(128 + i * 32), 32, 2, 0, range.first, range.second});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every address in the file, from the fixed block lengths above: the writer therefore knows
|
||||||
|
// where it is going before it writes a byte, which is what lets each block carry its links.
|
||||||
|
const std::string comment = "udpscope export: the visible window, " + std::to_string(rows) +
|
||||||
|
" samples x " + std::to_string(cols) + " signals";
|
||||||
|
const std::size_t hd_end = kMdfIdBlock + kMdfHeader;
|
||||||
|
const std::size_t comment_addr = hd_end;
|
||||||
|
const std::size_t data_addr = comment_addr + kMdfTxOverhead + comment.size();
|
||||||
|
const std::size_t dg_addr = data_addr + rows * record;
|
||||||
|
const std::size_t ch_addr = dg_addr + kMdfDataGroup;
|
||||||
|
const std::size_t cg_addr = ch_addr + channels * (kMdfConversion + kMdfChannel);
|
||||||
|
const std::uint64_t total = cg_addr + kMdfChannelGroup;
|
||||||
|
if (total > 0xFFFFFFFFull) {
|
||||||
|
error = "the export is larger than an MDF 3 file can address";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto cc_addr = [&](std::size_t i) {
|
||||||
|
return ch_addr + i * (kMdfConversion + kMdfChannel);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The wall clock the measurement file was written at. `abs_time` is the same instant in
|
||||||
|
// nanoseconds since the epoch, which is what the 3.2-era header field asks for.
|
||||||
|
const std::time_t now = std::time(nullptr);
|
||||||
|
std::tm tm{};
|
||||||
|
localtime_r(&now, &tm);
|
||||||
|
char date[11] = {};
|
||||||
|
char clock[9] = {};
|
||||||
|
std::strftime(date, sizeof(date), "%d:%m:%Y", &tm);
|
||||||
|
std::strftime(clock, sizeof(clock), "%H:%M:%S", &tm);
|
||||||
|
|
||||||
|
Bytes head;
|
||||||
|
// Identification block: the version string, who wrote the file, and the byte order every
|
||||||
|
// sample in it is in.
|
||||||
|
head.text("MDF ", 8);
|
||||||
|
head.text("3.30", 8);
|
||||||
|
head.text("udpscope", 8);
|
||||||
|
head.u16(0); // byte order: Intel, ahead of every value and the float format below
|
||||||
|
head.u16(0); // float format: IEEE
|
||||||
|
head.u16(330); // MDF version, digits without the separator
|
||||||
|
head.u16(0); // code page: latin-1
|
||||||
|
head.zeros(2); // reserved
|
||||||
|
head.zeros(26); // reserved
|
||||||
|
head.u16(0); // unfinalised standard flags
|
||||||
|
head.u16(0); // unfinalised custom flags
|
||||||
|
if (!mdf_block_size(head, kMdfIdBlock, "identification", error)) return false;
|
||||||
|
|
||||||
|
// Header block, which points at the data group and at the comment that follows it.
|
||||||
|
head.raw("HD", 2);
|
||||||
|
head.u16(static_cast<std::uint16_t>(kMdfHeader));
|
||||||
|
head.u32(static_cast<std::uint32_t>(dg_addr)); // first data group
|
||||||
|
head.u32(static_cast<std::uint32_t>(comment_addr));
|
||||||
|
head.u32(0); // program block: nothing wrote this but us
|
||||||
|
head.u16(1); // data groups
|
||||||
|
head.text(date, 10);
|
||||||
|
head.text(clock, 8);
|
||||||
|
head.text("", 32); // author
|
||||||
|
head.text("", 32); // department
|
||||||
|
head.text("", 32); // project
|
||||||
|
head.text("", 32); // subject
|
||||||
|
head.u64(static_cast<std::uint64_t>(now) * 1000000000ull);
|
||||||
|
head.i16(static_cast<std::int16_t>(tm.tm_gmtoff / 3600));
|
||||||
|
head.u16(0); // time quality
|
||||||
|
head.text("Local PC Reference Time", 32);
|
||||||
|
if (!mdf_block_size(head, hd_end, "header", error)) return false;
|
||||||
|
|
||||||
|
// The comment, in the one text block the file carries: plain words rather than the XML a
|
||||||
|
// writer that also *edits* files keeps its structured properties in.
|
||||||
|
head.raw("TX", 2);
|
||||||
|
head.u16(static_cast<std::uint16_t>(comment.size() + kMdfTxOverhead));
|
||||||
|
head.raw(comment.data(), comment.size());
|
||||||
|
head.zeros(1);
|
||||||
|
|
||||||
|
Bytes tail;
|
||||||
|
// Data group: one channel group, whose channels are chained, and the address of the samples.
|
||||||
|
tail.raw("DG", 2);
|
||||||
|
tail.u16(static_cast<std::uint16_t>(kMdfDataGroup));
|
||||||
|
tail.u32(0); // next data group
|
||||||
|
tail.u32(static_cast<std::uint32_t>(cg_addr));
|
||||||
|
tail.u32(0); // trigger block
|
||||||
|
tail.u32(static_cast<std::uint32_t>(data_addr));
|
||||||
|
tail.u16(1); // channel groups
|
||||||
|
tail.u16(0); // record IDs: the group is sorted
|
||||||
|
tail.zeros(4); // reserved
|
||||||
|
if (!mdf_block_size(tail, kMdfDataGroup, "data group", error)) return false;
|
||||||
|
|
||||||
|
for (std::size_t i = 0; i < channels; ++i) {
|
||||||
|
const Channel& c = chans[i];
|
||||||
|
// Conversion block: the unit lives here and nowhere else in MDF 3, which is where ScryLab
|
||||||
|
// reads a signal's unit from. NONE because the samples are already the physical values --
|
||||||
|
// the table was collected as `f32`, so a conversion would only be a second copy of it.
|
||||||
|
Bytes cc;
|
||||||
|
cc.raw("CC", 2);
|
||||||
|
cc.u16(static_cast<std::uint16_t>(kMdfConversion));
|
||||||
|
cc.u16(1); // range_flag: the range below is measured, so it is valid
|
||||||
|
cc.f64(c.lo);
|
||||||
|
cc.f64(c.hi);
|
||||||
|
cc.text(c.unit, 20);
|
||||||
|
cc.u16(0xFFFFu);
|
||||||
|
cc.u16(0); // referenced parameters
|
||||||
|
if (!mdf_block_size(cc, kMdfConversion, "conversion", error)) return false;
|
||||||
|
|
||||||
|
// Channel block: the name ScryLab turns into the signal name, where this channel's samples
|
||||||
|
// sit in a record, and the link to the next channel.
|
||||||
|
Bytes cn;
|
||||||
|
cn.raw("CN", 2);
|
||||||
|
cn.u16(static_cast<std::uint16_t>(kMdfChannel));
|
||||||
|
cn.u32(i + 1 < channels ? static_cast<std::uint32_t>(cc_addr(i + 1) + kMdfConversion) : 0);
|
||||||
|
cn.u32(static_cast<std::uint32_t>(cc_addr(i)));
|
||||||
|
cn.u32(0); // source: the stream does not say what produced a sample
|
||||||
|
cn.u32(0); // dependency block
|
||||||
|
cn.u32(0); // comment
|
||||||
|
cn.u16(c.type);
|
||||||
|
cn.text(c.name, 32);
|
||||||
|
cn.zeros(128); // description
|
||||||
|
cn.u16(c.bit_offset);
|
||||||
|
cn.u16(c.bit_count);
|
||||||
|
cn.u16(c.data_type);
|
||||||
|
cn.u16(1); // range_flag
|
||||||
|
cn.f64(c.lo);
|
||||||
|
cn.f64(c.hi);
|
||||||
|
cn.f64(0.0); // sampling rate: only a virtual time channel carries one
|
||||||
|
cn.u32(0); // long name
|
||||||
|
cn.u32(0); // display name
|
||||||
|
cn.u16(0); // additional byte offset
|
||||||
|
if (!mdf_block_size(cn, kMdfChannel, "channel", error)) return false;
|
||||||
|
|
||||||
|
tail.v.insert(tail.v.end(), cc.v.begin(), cc.v.end());
|
||||||
|
tail.v.insert(tail.v.end(), cn.v.begin(), cn.v.end());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel group, last: it names the first channel, the record size and the cycle count from
|
||||||
|
// which a reader derives the size of the sample block at `data_addr`.
|
||||||
|
tail.raw("CG", 2);
|
||||||
|
tail.u16(static_cast<std::uint16_t>(kMdfChannelGroup));
|
||||||
|
tail.u32(0); // next channel group
|
||||||
|
tail.u32(static_cast<std::uint32_t>(ch_addr + kMdfConversion));
|
||||||
|
tail.u32(0); // comment
|
||||||
|
tail.u16(1); // record ID
|
||||||
|
tail.u16(static_cast<std::uint16_t>(channels));
|
||||||
|
tail.u16(static_cast<std::uint16_t>(record));
|
||||||
|
tail.u32(static_cast<std::uint32_t>(rows));
|
||||||
|
tail.zeros(4); // sample reduction address
|
||||||
|
if (!mdf_block_size(tail, kMdfDataGroup + channels * (kMdfConversion + kMdfChannel) +
|
||||||
|
kMdfChannelGroup,
|
||||||
|
"data group and channels", error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
File out(path);
|
||||||
|
if (!opened(out, path, error)) return false;
|
||||||
|
const auto put = [&](const Bytes& bytes) {
|
||||||
|
return bytes.v.empty() || std::fwrite(bytes.v.data(), 1, bytes.v.size(), out.f) == bytes.v.size();
|
||||||
|
};
|
||||||
|
if (!put(head)) {
|
||||||
|
error = "short write to '" + path + "': " + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// One record at a time, in the channel order the blocks above declare, so a full ring is not
|
||||||
|
// held twice -- the same shape the `.npy` writer streams its records in.
|
||||||
|
std::vector<unsigned char> rec(record);
|
||||||
|
for (std::size_t i = 0; i < rows; ++i) {
|
||||||
|
std::size_t at = 0;
|
||||||
|
std::memcpy(rec.data() + at, &table.abs_seconds[i], sizeof(f64));
|
||||||
|
at += sizeof(f64);
|
||||||
|
std::memcpy(rec.data() + at, &table.time[i], sizeof(f64));
|
||||||
|
at += sizeof(f64);
|
||||||
|
for (const ExportTable::Column& col : table.columns) {
|
||||||
|
const f32 v = i < col.values.size() ? col.values[i] : 0.0f;
|
||||||
|
std::memcpy(rec.data() + at, &v, sizeof(f32));
|
||||||
|
at += sizeof(f32);
|
||||||
|
}
|
||||||
|
if (std::fwrite(rec.data(), 1, rec.size(), out.f) != rec.size()) {
|
||||||
|
error = "short write to '" + path + "': " + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!put(tail)) {
|
||||||
|
error = "short write to '" + path + "': " + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (std::fflush(out.f) != 0) {
|
||||||
|
error = "cannot flush '" + path + "': " + std::strerror(errno);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool write_export(const std::string& path, const ExportTable& table, std::string& error) {
|
||||||
|
const auto ends_with = [&path](const char* ext) {
|
||||||
|
const std::size_t n = std::strlen(ext);
|
||||||
|
return path.size() >= n && path.compare(path.size() - n, n, ext) == 0;
|
||||||
|
};
|
||||||
|
if (ends_with(".csv")) return write_csv(path, table, error);
|
||||||
|
if (ends_with(".mf3") || ends_with(".mdf")) return write_mdf(path, table, error);
|
||||||
|
return write_npy(path, table, error);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
@@ -21,6 +21,11 @@ namespace udpscope {
|
|||||||
struct ExportTable {
|
struct ExportTable {
|
||||||
struct Column {
|
struct Column {
|
||||||
std::string name; ///< the legend name, sanitised for a column label
|
std::string name; ///< the legend name, sanitised for a column label
|
||||||
|
/// The signal's unit exactly as the CONFIG carried it, the empty string when it
|
||||||
|
/// carried none. The `.npy`/`.csv` writers do not spell it out (their readers get the
|
||||||
|
/// names and the numbers); MDF does, because the unit *is* part of what a channel is
|
||||||
|
/// there, and it is the only thing ScryLab reads a signal's unit from.
|
||||||
|
std::string unit;
|
||||||
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
std::vector<f32> values; ///< `rows()` long, the ring's own `f32`
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -71,4 +76,27 @@ bool write_npy(const std::string& path, const ExportTable& table, std::string& e
|
|||||||
/// enough digits to round-trip each column's own type and no more.
|
/// enough digits to round-trip each column's own type and no more.
|
||||||
bool write_csv(const std::string& path, const ExportTable& table, std::string& error);
|
bool write_csv(const std::string& path, const ExportTable& table, std::string& error);
|
||||||
|
|
||||||
|
/// Writes the same table as **MDF 3.30** (`.mf3`/`.mdf`), byte for byte by hand -- there is no
|
||||||
|
/// writer library here and MF4 (HDF5) is the one format this app will not link.
|
||||||
|
///
|
||||||
|
/// One channel group, all channels record-interleaved, master first, which is the shape the
|
||||||
|
/// [ScryLab data sources](https://docs.scrylab.de/docs/data-sources/) contract asks for: *every
|
||||||
|
/// data channel becomes a signal* (the CNBLOCK's `short_name`), *the master channel of each
|
||||||
|
/// group is the x axis* (our master is `abs_seconds`, named `time`), and *the unit is taken from
|
||||||
|
/// the channel metadata* (MDF 3 keeps it in the channel's conversion block, the CCBLOCK). A
|
||||||
|
/// second occurrence of a name is dropped by that reader, so the labels are de-duplicated and cut
|
||||||
|
/// to the 31 bytes a CNBLOCK's name field holds.
|
||||||
|
///
|
||||||
|
/// The master is the **absolute** clock and the axis the plot showed is an ordinary channel,
|
||||||
|
/// `time_relative`: the relative column is `0` at a different sample in every mode, so a master
|
||||||
|
/// off it would put two exports from different modes on two timelines, while `abs_seconds` is the
|
||||||
|
/// number the stream itself stamped the samples with. Both carry the unit `s`.
|
||||||
|
bool write_mdf(const std::string& path, const ExportTable& table, std::string& error);
|
||||||
|
|
||||||
|
/// Writes `table` with the writer **`path`'s extension** names: `.csv` -> `write_csv`,
|
||||||
|
/// `.mf3`/`.mdf` -> `write_mdf`, anything else -> `write_npy` (the default, as the save dialog's
|
||||||
|
/// default filter is). The export dialog and the self-check's export probe both come through
|
||||||
|
/// here, so the two cannot offer different formats.
|
||||||
|
bool write_export(const std::string& path, const ExportTable& table, std::string& error);
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<b>What udpscope is</b>
|
|
||||||
|
|
||||||
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each with up
|
|
||||||
to 4 traces, and every row shares one time axis, so the whole figure scrolls together.
|
|
||||||
Acquisition runs on its own thread and writes a ring buffer sized from <tt>--buffer-mb</tt>;
|
|
||||||
the drawing thread reads whatever that ring holds when a frame starts, which is why a trace
|
|
||||||
never makes the window stutter.
|
|
||||||
|
|
||||||
The stream is the clock. Every interval you read on screen is measured between timestamps the
|
|
||||||
source sent, never your machine's, so a stalled source freezes the view instead of scrolling it
|
|
||||||
-- and a source whose clock steps still gets the window you asked for.
|
|
||||||
|
|
||||||
The time axis is <i>relative to now</i>. While streaming it is a fixed <tt>[-window .. 0]</tt>
|
|
||||||
with the newest sample at 0, so x reads as an age: <tt>-1.5s</tt> is a sample a second and a half
|
|
||||||
old. In trigger mode 0 is the event's edge instead, and a paused view keeps the ages it was
|
|
||||||
frozen with. The source's own clock is still there when you need it -- an export writes it as a
|
|
||||||
second column.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it with
|
|
||||||
no <tt>--server</tt> and pick one from the chooser, which lists the sessions you have opened
|
|
||||||
before. A unicast source serves one client at a time: if a second viewer connects, they take
|
|
||||||
turns and the status bar says so. To watch one source from several instances, use
|
|
||||||
<tt>--multicast</tt> with the group and <tt>--interface</tt> with the local address.
|
|
||||||
|
|
||||||
The signals arrive <i>disabled</i>. Nothing is acquired until you turn one on in the signal
|
|
||||||
list, and enabling is what starts acquisition -- so a freshly opened scope with an idle side
|
|
||||||
panel is working correctly, not failing to connect.
|
|
||||||
|
|
||||||
<b>The window</b>
|
|
||||||
|
|
||||||
• <b>The header bar</b> -- left to right: the time window (<tt>2 s</tt>), the trigger and the
|
|
||||||
pause, the events, cursors and side-panel switches, and the menu, which holds Fit to data, the
|
|
||||||
layout editor, Export, Keyboard shortcuts, Help and About.
|
|
||||||
|
|
||||||
• <b>The side panel</b> -- two tabs. <b>Measure</b> holds the cursors readout and the
|
|
||||||
measurements derived from it; <b>Signals</b> holds the trigger and the channel list, where a
|
|
||||||
math channel is a row of that one list under a <tt>MATH</tt> heading whose <tt>+</tt> creates
|
|
||||||
one. One row per channel: its colour, an enable switch, its name and its unit. Channels are
|
|
||||||
dragged from here onto a plot row.
|
|
||||||
|
|
||||||
• <b>The plot</b> -- up to 8 rows, each with its own y axis and up to 4 traces, all on one
|
|
||||||
shared time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).
|
|
||||||
|
|
||||||
• <b>The status bar</b>, directly above the plot's bottom edge -- the state word, the stored
|
|
||||||
sample rate and the window, and the ⓘ beside it for packets, gaps and the buffer's own
|
|
||||||
figures. The second screenshot above is what a held capture looks like: <tt>event held</tt>,
|
|
||||||
the axis pinned to the event, and the trigger marker at the edge.
|
|
||||||
|
|
||||||
<b>The first five minutes</b>
|
|
||||||
|
|
||||||
1. <b>Connect.</b> With no <tt>--server</tt>, pick a stored session or type a host and port.
|
|
||||||
2. <b>Enable a signal</b> in the signal list. The plot stays empty for the first fraction of a
|
|
||||||
second while the first window fills -- the buffer holds only what has arrived.
|
|
||||||
3. <b>Drag it onto a row.</b> Drop a second signal on another row, or the same signal again for
|
|
||||||
a second view of it. The colour is per signal, so it is the same one wherever it appears.
|
|
||||||
4. <b>Set the window</b> with the control in the header bar: type a number, pick a unit, or step
|
|
||||||
a decade. The trigger's event window is a share of this span, so a window change moves it.
|
|
||||||
5. <b>Pause</b> (<tt>space</tt>) to look at a still picture; the producer keeps filling the
|
|
||||||
buffer behind it, and resuming shows what arrived meanwhile rather than a hole.
|
|
||||||
6. <b>Arm the trigger</b> (<tt>a</tt>) and wait for a latch: the view freezes on one event, and
|
|
||||||
you can zoom in on it. Disarming mid-capture keeps what the capture got.
|
|
||||||
7. <b>Measure it</b> -- the measurements pane computes a statistic of the visible window, and
|
|
||||||
<b>show on plot</b> marks it as a ruler or a pair of markers.
|
|
||||||
8. <b>Save the data</b> with Export visible range, which writes the plotted signals over the
|
|
||||||
window at the stored rate to <tt>.npy</tt> or <tt>.csv</tt>.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• A signal nobody enables costs nothing -- no buffer, no socket work, no measurements.
|
|
||||||
• The window remembers itself. Rows, traces, the time window, calibration, which signals were
|
|
||||||
enabled, the math channels with their graphs, and the measurements are written per host and
|
|
||||||
port, and reloaded the next time you open that source.
|
|
||||||
• A very wide window on a fast stream is drawn from averaged groups of samples, so the rate in
|
|
||||||
the status bar falls: that is the anti-alias filter, not a fault -- striding would fold a fast
|
|
||||||
signal back as a slow one, which looks entirely plausible on screen.
|
|
||||||
• Every instrument the app computed -- the trigger marker, the measurement marks -- is locked to
|
|
||||||
the pointer's reach. The cursors are yours to drag; those are not.
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
<b>Choosing a source</b>
|
|
||||||
|
|
||||||
A source is a host and a port, optionally reached through a multicast group on a named
|
|
||||||
interface. Pass it on the command line, or let udpscope ask.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
Run with <tt>--server</tt> and the app connects straight away. Run <i>without</i>
|
|
||||||
<tt>--server</tt> and a chooser appears first: one row per stored <i>configuration</i>
|
|
||||||
(<tt>host:port · name</tt>, its note, and the settings it carries), newest first, plus a
|
|
||||||
<b>New host…</b> form that takes the address <i>and</i> the configuration it belongs to — its name
|
|
||||||
and note, the <b>buffer size</b>, and whether time is relative or absolute. A row's pencil button
|
|
||||||
opens the same form on that configuration, pre-filled, and changes to its name or address move it
|
|
||||||
(the old file is deleted).
|
|
||||||
|
|
||||||
The second page, <b>Waveforms</b>, opens an <i>exported</i> run instead of a live source: the files
|
|
||||||
opened before (newest first; one that has since been deleted is marked <i>missing</i>) and an
|
|
||||||
<b>Open…</b> button for a new one. The same file can be opened straight from the command line with
|
|
||||||
<tt>--open FILE</tt>. A waveform is a source that never arrives — no socket, no acquisition, no
|
|
||||||
starvation notice — and every instrument works on it: cursors, measurements, math channels and
|
|
||||||
another export. Only <tt>.npy</tt> files are read. form for one it has not seen. The chooser runs before any
|
|
||||||
buffer is allocated and before any socket is opened, so declining it costs nothing.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• The list is the sessions that <i>named a signal</i>. A session whose streamer was down
|
|
||||||
stores empty slot names and is filtered out, which is what keeps the list the sources you
|
|
||||||
actually configured rather than every port you have ever typed.
|
|
||||||
• A stored row reconnects the way it was reached. The multicast group and the interface are
|
|
||||||
written inside the session file, because the file's name (<tt>host_port.toml</tt>) cannot
|
|
||||||
carry them.
|
|
||||||
• <tt>--multicast</tt> still needs <tt>--server</tt>: the CONFIG message is fetched from the
|
|
||||||
server, not from the group.
|
|
||||||
• The <tt>×</tt> beside a row deletes that session file. Nothing else is removed.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
<tt>udpscope</tt> with two stored sessions lists
|
|
||||||
<tt>127.0.0.1:8000 plots=1 signals=3</tt> and
|
|
||||||
<tt>10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9</tt>.
|
|
||||||
Activating the first reports <tt>[sources] picked 127.0.0.1:8000</tt> and continues into
|
|
||||||
the scope with that session's layout already loaded.
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
<b>Signals and calibration</b>
|
|
||||||
|
|
||||||
The source's CONFIG message describes its signals: a name, a unit and a type. udpscope turns
|
|
||||||
each enabled signal into a <i>channel</i> — a ring of values beside the shared time ring —
|
|
||||||
and a channel is what a trace plots. Calibration is a scale and an offset applied at ingest.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
The side panel (<tt>s</tt>) lists every signal with a switch. The <b>Signal list</b> item in
|
|
||||||
the overflow menu opens the editor where the unit and the calibration live.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
|
|
||||||
nobody acquires can never fire, so arming a trigger enables its signal for you.
|
|
||||||
• <b>Calibrate</b> in a signal's row menu opens the gain and offset it is <i>currently</i> drawn
|
|
||||||
through, so a trace already scaled by a gain of 2 shows 2 — pressing <i>Apply</i> confirms it
|
|
||||||
rather than resetting it. A signal nobody calibrated shows the identity, 1 and 0.
|
|
||||||
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
|
|
||||||
history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
|
|
||||||
for a second after one of those is the ring refilling, not a fault.
|
|
||||||
• The stream's first signal is usually its <i>timestamp</i>, and that one is never acquired.
|
|
||||||
It cannot be plotted, measured or used as a math source, and udpscope refuses to put it in
|
|
||||||
a row rather than binding a trace that would read nothing for ever.
|
|
||||||
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
|
|
||||||
is one colour and the side panel's chip identifies it unambiguously. A signal on no row
|
|
||||||
gets a grey chip rather than none, so the panel's rows keep their height.
|
|
||||||
• <b>Click a coloured chip to change that signal's colour.</b> A grey chip names no trace, so
|
|
||||||
it is not clickable. The colour is saved with the session; <b>Default colour</b> in the
|
|
||||||
signal's ⋯ menu gives the palette entry back.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
A source declaring <tt>time</tt>, <tt>sine</tt>, <tt>ramp</tt> and <tt>noise</tt> yields three
|
|
||||||
plottable signals. Enable all three and the exit dump reads <tt>3 signals, 3 channels</tt>;
|
|
||||||
<tt>time</tt> is not among them.
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
<b>Rows, layout and drag & drop</b>
|
|
||||||
|
|
||||||
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
|
|
||||||
time axis and one pair of side margins, so their y labels line up, their data areas start and
|
|
||||||
end on the same two columns, and their windows cannot drift apart. The left margin holds the y
|
|
||||||
labels; the right one is empty, and is there so the newest tick's label and the newest sample's
|
|
||||||
ink have somewhere to land instead of sitting on the frame.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
Drag a signal's chip out of the side panel (<tt>s</tt>) and drop it on a row to add a trace.
|
|
||||||
Press <tt>l</tt>, or pick <b>Layout editor</b> from the overflow menu, to add and remove rows:
|
|
||||||
a <tt>×</tt> appears at the top left of each row and a <tt>+</tt> on each boundary between
|
|
||||||
rows, including above the first and below the last. The editor's own button in the top-right
|
|
||||||
corner leaves it.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• A plot's own <b>right-click menu</b> (the same compact rows as the app's menu) holds the
|
|
||||||
y-axis scale, the two fits, and <b>Remove signal</b>, which swaps the menu to that plot's traces
|
|
||||||
plus <i>All traces on this plot</i> — with a <b>Back</b> row to return. That is how a trace comes
|
|
||||||
off a row without going through the layout editor.
|
|
||||||
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
|
|
||||||
traces already on a row the drop is refused.
|
|
||||||
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
|
|
||||||
puts what row 1 plotted onto row 2 and leaves the new row empty — the rows are not
|
|
||||||
renumbered under you.
|
|
||||||
• A trace is remembered by the signal's <i>name</i>, not by its channel number, because a new
|
|
||||||
CONFIG or an enable renumbers the channels and an index would silently re-point the trace.
|
|
||||||
• Adding or removing a row rebuilds the figure, which is cheap, but it also re-deals every
|
|
||||||
trace. A frozen trigger event survives the rebuild: the pinned window is restated rather
|
|
||||||
than re-framed on the live edge.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
With three traces on row 1, pressing <tt>l</tt> shows 3 buttons (one <tt>×</tt>, two
|
|
||||||
<tt>+</tt>). Clicking the <tt>+</tt> above row 1 gives 2 rows and 5 buttons, with the three
|
|
||||||
traces now on row 2 — the layout probe reports exactly this as
|
|
||||||
<tt>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</tt>.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<b>Time window</b>
|
|
||||||
|
|
||||||
The window is how much time the plot shows. Every row shares one window: the figure has a
|
|
||||||
single x link group, so the rows cannot drift apart. The trigger's event span follows it, and
|
|
||||||
the pre-trigger share is a share of <i>this</i> window.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
The header bar's window button opens a popover: type a number, pick a unit under it, or step a
|
|
||||||
decade with the arrows. The button's own label is a reading of the current window in a natural
|
|
||||||
unit, width-locked so a change of window cannot widen the header bar.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• A window too long to store at the wire rate is <i>decimated, not refused</i>. The shared clock
|
|
||||||
takes a power-of-two stride, and each group of samples is <b>averaged</b> rather than strided,
|
|
||||||
so a tone above the stored Nyquist is attenuated instead of aliasing to a false low frequency.
|
|
||||||
• The stamp stored for a group is the group's <i>mean</i>, which is where the averaging puts the
|
|
||||||
value. Stamping at the end of a group would shift every trace half a stored sample late.
|
|
||||||
• Moving the window across a decimation boundary rebuilds the buffer and drops the history.
|
|
||||||
Walking it inside one decade is free.
|
|
||||||
• <b>This control is the only thing that changes the window.</b> A zoom, a pan or a fit moves the
|
|
||||||
view <i>inside</i> it and leaves it alone — which is the point of the previous bullet: a wheel
|
|
||||||
notch that redefined the window could re-plan the buffer and throw your samples away.
|
|
||||||
• The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge
|
|
||||||
found on a sample that is not in the buffer could not be cut out of it.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
1 MS/s with 200 MB of buffer. A 2 s window stores every sample (<tt>time x1</tt>). Widen it to
|
|
||||||
600 s and the stride becomes <tt>x16</tt>: 625 S/s stored, anti-aliased at 312.5 Hz. A 2 kHz
|
|
||||||
sine seen through that window reads <tt>y=[-0.059, 0.079]</tt> — attenuated to about 6%, not
|
|
||||||
folded down to a full-amplitude 125 Hz one.
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
<b>Navigating the plot</b>
|
|
||||||
|
|
||||||
The plot pans and zooms with the mouse and with the keyboard. Every gesture acts on the row the
|
|
||||||
pointer is over, and because the rows share one time axis, an x change moves all of them.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
|
|
||||||
<b>Drag</b> pans · <b>Shift+drag</b> is a box zoom on both axes · <b>Ctrl+drag</b> on x only ·
|
|
||||||
<b>Alt+drag</b> on values only.
|
|
||||||
The area being chosen is framed while you drag it, so you can see what the release will apply.
|
|
||||||
<b>+</b> and <b>-</b> zoom both axes; <b>Ctrl</b> with them restricts to x, <b>Alt</b> to y.
|
|
||||||
<b>Arrow keys</b> pan, <b>f</b> fits, <b>Escape</b> cancels a gesture in progress.
|
|
||||||
The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• The keyboard zoom's modifiers are <i>not</i> the wheel's, and that is deliberate: on common
|
|
||||||
layouts <tt>+</tt> <i>is</i> shift+<tt>=</tt>, so a bare zoom-in already arrives with Shift
|
|
||||||
set and Shift could not mean anything. Ctrl/Alt mirror the box-zoom drags instead.
|
|
||||||
• A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is
|
|
||||||
held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not
|
|
||||||
outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data.
|
|
||||||
• Nothing you do on the plot changes the <i>time window</i> — only the header control does. A
|
|
||||||
zoom or a pan moves the view inside the window; <b>f</b> (and Fit X axis) is the <i>undo</i> of a
|
|
||||||
zoom, so a 2 s window zoomed into reads 2 s again. The window is more than the axis: it is also
|
|
||||||
the trigger's event span and how the samples are stored, so a wheel notch that redefined it
|
|
||||||
would re-plan the buffer and drop your history.
|
|
||||||
• The axis does not scroll — the <i>data</i> does. A live axis is a fixed <tt>[-window .. 0]</tt>
|
|
||||||
with the newest sample at 0, so the labels stand still and the trace rolls under them. The
|
|
||||||
cursors stand still with the labels (they are positions on screen, so a cursor you placed stays
|
|
||||||
where you put it, over newer data each frame), while a measurement's marks travel with the
|
|
||||||
sample they measured and leave the view when it does.
|
|
||||||
• A live view is clamped to the window you asked for, not to what the ring happens to hold.
|
|
||||||
That is why a buffer that has only just started filling leaves its left-hand side empty
|
|
||||||
instead of squeezing the window down to the first few samples.
|
|
||||||
• Zooming hard into a held capture is exact. The renderer finds the visible samples by binary
|
|
||||||
search over the real timestamps, never over a nominal sample grid, so a capture whose clock
|
|
||||||
wandered does not drift away from its axis as you zoom.
|
|
||||||
• A trace holding far more samples than the plot has pixels is drawn <i>one pixel column at a
|
|
||||||
time</i>: each column is a single vertical line from the lowest to the highest value that falls
|
|
||||||
in it. Drawn as a polyline instead, such a window puts three or four near-vertical lines in
|
|
||||||
every column at slightly different sub-pixel positions, and how they overlap beats against the
|
|
||||||
pixel grid — which reads as bands of light and dark that <i>crawl</i> as you pan. One line per
|
|
||||||
column removes the beat, and it is also much faster: a 1 MS/s view costs a couple of thousand
|
|
||||||
line segments a frame instead of a couple of hundred thousand.
|
|
||||||
• Those columns are <i>graded by brightness</i>, the way a digital-phosphor scope reads: bright
|
|
||||||
where the signal spent its time, faint where it only passed through. A column's highest and
|
|
||||||
lowest value are the same whether the samples in it are spread evenly or piled at its two ends,
|
|
||||||
so the grading is what tells the two apart — the flat parts of a square wave come out solid and
|
|
||||||
the edges between them faint. It is read from a finer division of each column rather than from
|
|
||||||
the line that is drawn, and it costs about a millisecond a frame.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
Pause a live view, then press <tt>Ctrl++</tt>: x narrows and y does not move. Press
|
|
||||||
<tt>Alt++</tt>: y narrows and x does not. Eight doublings into a held 2 s capture, the trace
|
|
||||||
still reaches both margins — the zoom probe reports <tt>gap_px</tt> and <tt>end_px</tt> within
|
|
||||||
a pixel or two at every step.
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<b>Pause and play</b>
|
|
||||||
|
|
||||||
Pause freezes the <i>display</i>, not the acquisition. The producer keeps filling the ring at
|
|
||||||
full rate; the view binds an immutable snapshot of the window that was on screen, and the axis
|
|
||||||
is pinned to it.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
The pause button in the toolbar, or <tt>Space</tt>. Press it again to resume, which rebinds the
|
|
||||||
live ring and puts the window back on the newest data.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• Nothing is lost while paused. Because the producer never stops, resuming shows current data
|
|
||||||
with no hole in the history behind it.
|
|
||||||
• The axis is pinned to the <i>snapshot's own stamps</i>, not to the window that was requested.
|
|
||||||
A window reaching back past the oldest retained sample yields a shorter snapshot, and the
|
|
||||||
pinned axis is that shorter span — so you see a narrower view rather than an empty margin.
|
|
||||||
• A paused view is pinned exactly as a held trigger event is: zoom in freely, but a pan or a
|
|
||||||
zoom out is clamped to the snapshot's data.
|
|
||||||
• Pausing is the easiest way to measure a layout or read a cursor precisely, because nothing
|
|
||||||
under the pointer moves between frames.
|
|
||||||
• The status bar says how long you have been paused — wall time, since the stream itself never
|
|
||||||
stopped. It is there so a frozen picture cannot be mistaken for a live one that went quiet.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads
|
|
||||||
<tt>[-1.40 .. 0.00]</tt> rather than <tt>[-2.00 .. 0.00]</tt> with 0.6 s of blank. The pause
|
|
||||||
probe reports <tt>ring_produced</tt> still growing across the pause and
|
|
||||||
<tt>event_size</tt> non-zero exactly while paused.
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
<b>Trigger</b>
|
|
||||||
|
|
||||||
The trigger watches one signal for a condition and captures a window around it. The
|
|
||||||
<b>Shape</b> dropdown chooses what counts as an event; the arming, hold-off, pre/post window
|
|
||||||
and Normal/Single mode are the same for all four shapes.
|
|
||||||
The captured window is frozen on screen until you re-arm.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
The trigger button in the toolbar (<tt>t</tt>) reveals the trigger panel: the signal, the level,
|
|
||||||
the shape, the edge (rising, falling, either), the mode (Normal or Single) and the pre-trigger
|
|
||||||
share. <b>Arm</b> starts watching; <tt>a</tt> arms and disarms from the keyboard.
|
|
||||||
<b>Search the buffer</b> is at the foot of the same panel.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• <b>Every triggered acquisition is kept.</b> Latches are written to the event cache
|
|
||||||
(<tt>~/.cache/udpscope/events/<source>/</tt>) as a <tt>.npy</tt> of the event, a
|
|
||||||
<tt>.txt</tt> of what it was, and a <tt>.bmp</tt> picture drawn from the samples. The
|
|
||||||
<b>Events</b> pane on the left -- opened from the toolbar's event-history button, which appears
|
|
||||||
while recording is enabled, and collapsed until you ask for it -- lists the last 32 newest first
|
|
||||||
(time, duration, signals, rate), each row with its own picture beside the numbers. Clicking a row
|
|
||||||
<i>replays</i> it: the stored event is loaded exactly like an exported file, <b>without changing the
|
|
||||||
layout</b> -- the rows, the colours, the window, the cursors and the measurements stay where they
|
|
||||||
are and read the stored data instead. A replay leaves trigger mode, because a file has no edge to
|
|
||||||
wait for, and the pane stays open. Drag the pane's edge to resize it. <b>Live</b> goes back to the
|
|
||||||
stream; the status bar names the file until you do.
|
|
||||||
Select two or more events in the list and the two buttons under the header act on them:
|
|
||||||
<b>Compare</b> puts two events in two plots side by side, keeping the layout you have -- one plot per
|
|
||||||
event, the axes synchronised, so zooming a row in one zooms the same row in the other -- and
|
|
||||||
<b>Accumulate</b> overlays up to four of them in the same traces, translucent, so where they agree
|
|
||||||
the trace is brighter. Your own traces stay where they are in both. They need events with the same
|
|
||||||
signals and the same length. A single click selects, a <i>double</i> click replays.
|
|
||||||
<b>Record events</b>, in the trigger panel with the trigger's other options, is what decides whether
|
|
||||||
anything is kept. It is <b>off by default</b> -- nothing is written to disk until you ask -- and
|
|
||||||
with it off the history button goes with it.
|
|
||||||
|
|
||||||
• Arming <i>enables</i> the signal if it was off, because a trigger on a signal nobody acquires
|
|
||||||
can never fire.
|
|
||||||
• The pre-trigger share is a share of the <i>display window</i>, not of the ring. A 2 s window
|
|
||||||
at 50% captures 1 s before the edge and 1 s after.
|
|
||||||
• There are four display states, and the status bar names each: <tt>armed</tt> (watching,
|
|
||||||
nothing drawn yet), <tt>capturing</tt> (the edge was accepted and the post window is filling
|
|
||||||
<i>in view</i>, with the marker at x = 0), <tt>event held</tt> (the frozen capture) and
|
|
||||||
<tt>disarmed</tt>. A held capture is never quietly replaced by live data.
|
|
||||||
• The first edge after arming is only accepted once the history reaches back a full pre-trigger
|
|
||||||
window, so a capture may legitimately be shorter than you asked for. The axis is pinned to
|
|
||||||
what the capture actually got.
|
|
||||||
• Disarming <i>during</i> a capture publishes what the capture got to, so the picture on screen
|
|
||||||
at that moment stays on screen. No event fired, so the event count does not move.
|
|
||||||
• The trigger reads the <b>stored</b> signal — decimated and averaged — not the wire signal.
|
|
||||||
A spike narrower than one decimation group is filtered away rather than triggering on data
|
|
||||||
the buffer does not contain.
|
|
||||||
• The four shapes: <b>edge</b> — a crossing of the level in the chosen direction, the classic
|
|
||||||
behaviour. <b>Pulse width</b> — a crossing and its return, qualified by how long the excursion
|
|
||||||
lasted: less than, greater than, within or outside a pair of widths; the event is timed at the
|
|
||||||
closing edge. <b>Window</b> — the signal entering or leaving a band between two levels; the
|
|
||||||
panel keeps the two levels at least one increment apart, because a zero-width band is not a
|
|
||||||
band: the condition becomes an exact float-equality test against the level, which an ordinary
|
|
||||||
signal never satisfies and a signal sitting at the level satisfies on every transition.
|
|
||||||
<b>Dropout</b> —
|
|
||||||
the absence of a crossing for longer than a timeout; one event per outage, re-arms when a
|
|
||||||
crossing returns.
|
|
||||||
• The shape's own rows are the only ones shown; the rest are hidden, so the panel never offers
|
|
||||||
a pulse width to a window trigger.
|
|
||||||
• <b>Search the buffer</b> runs the same condition over the whole retained ring and lists every
|
|
||||||
place it holds. The arrows step through hits; each step pauses on the hit, centred, exactly as
|
|
||||||
the Pause button does. Up to 16 hits in the visible window are marked on every row; when there
|
|
||||||
are more the panel says how many and suggests zooming in. The search uses the trigger's own
|
|
||||||
condition, set in one place, so a hit is by construction an instant the trigger would have fired.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans
|
|
||||||
<tt>[-1.000 .. 1.000]</tt> with <tt>n=20001</tt> samples at 10 kS/s — the span times the real
|
|
||||||
rate, plus one — and the marker sits at x = 0 throughout.
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
<b>Cursors</b>
|
|
||||||
|
|
||||||
Cursors read values off the traces. There are two kinds: one <i>shared vertical pair</i> that
|
|
||||||
every row carries at the same time, and <i>horizontal rulers</i>, one pair per row, that belong
|
|
||||||
to that row alone.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
The cursors button in the toolbar, or <tt>c</tt>. The pane below the plot shows the readings and
|
|
||||||
owns a <b>Reset</b> button. Drag a marker to move it. The mode dropdown offers four modes:
|
|
||||||
<tt>vertical</tt> (the shared time pair), <tt>horizontal</tt> (the per-row rulers),
|
|
||||||
<tt>both</tt>, and <tt>mix</tt>.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• The vertical pair is <i>one</i> time for the whole figure, so moving it on any row moves it on
|
|
||||||
all of them. That is what makes <tt>dt</tt> meaningful across rows.
|
|
||||||
• A ruler belongs to its row and nothing shares it, so two rows' rulers are independent.
|
|
||||||
• <tt>mix</tt> is the reading mode: one line per <i>bound</i> trace, with the trace's own values
|
|
||||||
at each marker and their difference. A slot with a name but no channel behind it is not a
|
|
||||||
trace and gets no line.
|
|
||||||
• A value is the <i>nearest stored sample's</i>, never a number between two samples. Zoom in past
|
|
||||||
the sample spacing and the line between two samples is something the plot drew, not data —
|
|
||||||
so a reading off it would be the drawing. The hover crosshair reads the same way.
|
|
||||||
• Every reading carries a <i>metric prefix</i> so the digits sit in <tt>1..999</tt>:
|
|
||||||
<tt>10.52 mV</tt>, not <tt>0.01 V</tt>. Two decimals is all an instrument landing on an
|
|
||||||
<tt>f32</tt> sample can claim, so without the prefix a small value would read <tt>0.00</tt>
|
|
||||||
and the reading would be gone. A stream that declares no unit gets the prefix anyway, written
|
|
||||||
tight against the number (<tt>585.24m</tt>) — the space separates the number from its unit,
|
|
||||||
and a spaced <tt>m</tt> with nothing after it would read as metres.
|
|
||||||
• A marker past the newest sample reads <tt>--</tt>. With only one marker over data the row
|
|
||||||
reads <tt>partial</tt>: a difference between a number and nothing is not a number.
|
|
||||||
• In <tt>mix</tt> each marker also places a crossing annotation per trace, which is what lets
|
|
||||||
you see on the plot which sample a number came from. It is <i>on that sample</i>, so when the
|
|
||||||
marker falls between two it sits a little off its own line: the line is where you put the
|
|
||||||
marker, the dot is the datum being quoted. The hover crosshair's mark snaps the same way, and
|
|
||||||
is drawn larger than a trace's sample dots so it does not read as one more sample.
|
|
||||||
• The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
|
|
||||||
time row, by design.
|
|
||||||
• A cursor <i>stays where you put it</i> while a live view rolls under it, which is what a bench
|
|
||||||
scope does: its <tt>T0</tt> is an age, so a marker left at <tt>-1.5s</tt> keeps reading the
|
|
||||||
sample a second and a half old as the stream advances. A measurement's marks do the opposite —
|
|
||||||
they travel with the sample they measured — because a cursor is a question you asked and a mark
|
|
||||||
is an answer about one particular sample.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
Three traces in <tt>mix</tt> mode with both markers over data: 3 lines and 6 crossing marks,
|
|
||||||
each line reading like <tt>sine 0.31 V -0.84 V -1.15 V</tt>. Move the second marker past the
|
|
||||||
newest sample and the third column reads <tt>--</tt>.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<b>Measurements</b>
|
|
||||||
|
|
||||||
A measurement is a statistic of the <i>visible window</i> of one signal, recomputed as that
|
|
||||||
window moves. The pane lists them with their values; each can also draw its result on the plot.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
The measurements button in the toolbar, or <tt>m</tt>. The pane's <tt>+</tt> adds one; clicking
|
|
||||||
a row opens the dialog where the signal, the type, the threshold share and the on-plot marks are
|
|
||||||
set, with <b>Save</b> and <b>Delete</b>.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• The types are average, top, bottom, min, max, peak to peak, amplitude, pulse duration, period
|
|
||||||
and frequency. <i>top</i> and <i>bottom</i> are the means of the upper and lower halves, which
|
|
||||||
is why a unit sine reads 0.637 (2/π) rather than 1.
|
|
||||||
• The on-plot marks come in two kinds and the mix is the reading: a <i>level</i> mark is a ruler
|
|
||||||
at a value, a <i>time</i> mark is a marker at a position. An average draws one level mark; a
|
|
||||||
max draws a level mark <i>and</i> a time mark at the first sample that reached it.
|
|
||||||
• Marks cannot be dragged. A measurement mark says what the measurement measured, so it is
|
|
||||||
created unselectable — as is the trigger marker.
|
|
||||||
• A pass is throttled to a cadence rather than run every frame, so a ruler over a live view is
|
|
||||||
<i>stepped</i>. The number and the ruler come from the same pass, so they always agree.
|
|
||||||
• An invalid pass keeps its <i>level</i> marks and drops its <i>time</i> marks. A level is a
|
|
||||||
property of the signal and outlives the window; a position inside the window does not. That is
|
|
||||||
what stops the rulers blinking off at a mode change.
|
|
||||||
• A crossing is counted with <i>hysteresis</i>, not on a bare level: the thresholds are a tenth of
|
|
||||||
the window's peak to peak either side of the share, and an edge is counted only once the signal
|
|
||||||
has passed the far one. Period, frequency and pulse duration all depend on it. The mid level is
|
|
||||||
exactly where a waveform moves fastest, so any interferer faster than the fundamental recrosses a
|
|
||||||
single level several times per real crossing — a 5 Hz sine with a 500 Hz tone at a tenth of the
|
|
||||||
amplitude reads 39 Hz without the band and 5 Hz with it. The band holds to an interferer a third
|
|
||||||
of the waveform's own size.
|
|
||||||
• The band is clamped to stay inside the data, which is what keeps a threshold set near an extreme
|
|
||||||
usable: a fixed band there would sit above every sample and the measurement would read nothing.
|
|
||||||
A share near an extreme still leaves the band little room, so it rejects less — which is one
|
|
||||||
reason period and frequency offer no share to set.
|
|
||||||
• A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible
|
|
||||||
invented number. Frequency over a window holding one edge is the usual case.
|
|
||||||
• <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds;
|
|
||||||
every other type carries the channel's own unit. A value is written with a <i>metric prefix</i>
|
|
||||||
so its digits sit in <tt>1..999</tt> — <tt>636.62 mV</tt>, not <tt>0.64 V</tt> — including when
|
|
||||||
the stream declares no unit at all, which is always the case for a math channel: there the
|
|
||||||
prefix goes tight against the number (<tt>636.62m</tt>), since two decimals of an unprefixed
|
|
||||||
small value is <tt>0.00</tt>.
|
|
||||||
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
|
|
||||||
window. The values are still exact: a bucket's sum is a sum.
|
|
||||||
• <b>Statistics</b> adds a second line to every measurement showing <tt>n</tt>, the mean, the
|
|
||||||
standard deviation and the range. Each observation is one <i>recompute</i> of the pane, so
|
|
||||||
<tt>n</tt> counts windows measured, not samples — a mean frequency over many windows is what
|
|
||||||
that claims to be. <b>Reset</b> clears every accumulator and starts fresh. A change of display
|
|
||||||
window, a CONFIG or a measurement edit also resets; a pause, a resume, a zoom, a pan and the
|
|
||||||
toggle itself do not, so turning the line off to read the plot and back on again keeps the history.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
On a 1 Hz ±1 sine: <tt>average=0</tt>, <tt>top=0.636620</tt>, <tt>bottom=-0.636620</tt>,
|
|
||||||
<tt>max=1</tt>, <tt>min=-1</tt>, <tt>peak to peak=2</tt>, <tt>amplitude=1</tt>,
|
|
||||||
<tt>pulse duration=0.5 s</tt> (duty 0.5), <tt>period=1 s</tt>, <tt>frequency=1 Hz</tt>. Over a
|
|
||||||
2 M-sample window those two passes read about 0.1% of the samples.
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
<b>Math channels</b>
|
|
||||||
|
|
||||||
A math channel is a signal computed from other signals. You build it as a small graph — source
|
|
||||||
nodes, operations, one output — and udpscope then treats the result as an ordinary channel.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel, and opening
|
|
||||||
one shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
|
|
||||||
the canvas. Inside the editor, <i>add node</i> is a menu button whose popover picks the op, so the
|
|
||||||
node arrives as the operation you asked for.
|
|
||||||
|
|
||||||
<b>Graph gestures:</b> <b>drag</b> from an output port to an input port to wire them; drag a
|
|
||||||
wired input port off its connection to detach it; click a wire and press <tt>Delete</tt> to remove
|
|
||||||
it; drag a box to move it. <b>Wheel</b> zooms about the pointer. <b>Drag the background</b> to
|
|
||||||
pan. <tt>f</tt> fits all nodes into view; <tt>Ctrl+0</tt> resets the view. <i>tidy</i>, the button
|
|
||||||
beside <i>fit</i> in the editor, lays the graph out in topological columns and frames it. The orange
|
|
||||||
dot marks the output node; click <i>set as output</i> to move it.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
|
|
||||||
wire drawn right to left re-sorts the list silently so the graph stays valid. A cycle is
|
|
||||||
unrepresentable: the connection is refused rather than created, and the line beneath the form
|
|
||||||
says why — a loop, or a node asked to read itself.
|
|
||||||
• A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted,
|
|
||||||
measured, exported, and used as a source for another math channel.
|
|
||||||
• The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
|
|
||||||
Hz means the rate the filter will actually run at. Change the window across a decimation
|
|
||||||
boundary and the coefficients are rebuilt for the new rate.
|
|
||||||
• A source may only name an <i>acquired</i> channel. The stream's timestamp signal is not one:
|
|
||||||
a graph sourced from it validates and then reads NaN for ever, so the dropdown does not
|
|
||||||
offer it.
|
|
||||||
• The output defaults to the <i>last</i> node, and a node added to a linear graph becomes the
|
|
||||||
output. Choosing an earlier node explicitly is respected.
|
|
||||||
• Every numeric field starts at its own identity value — gain 1, offset 0 — not at the bottom
|
|
||||||
of its range.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
<tt>[sine] → [gain x2]</tt> gives a channel reading <tt>y=[-2, 2]</tt>. A 100 Hz low pass on the
|
|
||||||
same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at
|
|
||||||
0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel must hold exactly as many samples as
|
|
||||||
the shared clock has time slots.
|
|
||||||
|
|
||||||
<b>Why an operation sometimes does nothing</b>. A graph's <i>output</i> is the node whose value
|
|
||||||
becomes the channel, and it is drawn as the orange dot. A new graph is built source, then operation,
|
|
||||||
and the last node added becomes the output -- but a graph whose output is left on the source is
|
|
||||||
valid and simply passes its input through, which is the usual reason a gain or a filter appears to
|
|
||||||
have no effect. <i>Set as output</i> moves the dot.
|
|
||||||
|
|
||||||
<b>Apply is insensitive while the graph is incomplete.</b> A line beneath the form says why:
|
|
||||||
an input that is not connected, no output chosen, or a <i>channel</i> node naming a signal the
|
|
||||||
scope is not acquiring. Only the channels being acquired are offered, because a source that is not
|
|
||||||
acquired reads as no data for ever — and a graph that reads NaN is harder to diagnose than one that
|
|
||||||
would not save.
|
|
||||||
|
|
||||||
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
|
|
||||||
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
|
|
||||||
against the <i>stored</i> rate, which is the wire rate until a wide window makes the buffer decimate
|
|
||||||
-- the status line's rate is that number, and the filters are rebuilt whenever it changes.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<b>Saving and export</b>
|
|
||||||
|
|
||||||
Export writes the <i>visible range</i> of every plotted trace to a file: NumPy <tt>.npy</tt> or
|
|
||||||
<tt>.csv</tt>, chosen by the extension you type.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
<b>Export visible range…</b> in the overflow menu, or <tt>Ctrl+E</tt>. A file chooser asks where;
|
|
||||||
the format follows the path's extension.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• The table is collected at the moment you click, not when the chooser returns. A live view keeps
|
|
||||||
scrolling while a dialog is up, so a late collection would write a window you never saw.
|
|
||||||
• The rows are the <b>stored samples</b>, never the LOD buckets the plot draws from. A row count
|
|
||||||
far below the span times the stored rate means something cut the table from the pyramid.
|
|
||||||
• The stored rate is not always the wire rate. A window long enough to need decimation is stored
|
|
||||||
averaged at a lower rate, and the export carries <i>that</i> — read the status bar's ⓘ popover
|
|
||||||
for the figure before trusting a sample count.
|
|
||||||
• Two time columns. <tt>time</tt> is the display timeline — negative ages ending at 0 while
|
|
||||||
streaming, offsets from the trigger edge while an event is held — so it is the x you read a
|
|
||||||
value at on screen. <tt>abs_seconds</tt> is the source's own clock for the same sample, which
|
|
||||||
is what lets two exports taken in different modes line up. Both are strictly increasing and
|
|
||||||
evenly spaced; duplicate or backwards stamps would mean the file held two clocks.
|
|
||||||
• An export taken <i>mid-capture</i> writes the partly filled window that is on screen, which is
|
|
||||||
shorter than the event span. Export outside a capture if you want the whole event.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
|
|
||||||
(<tt>time</tt>, <tt>abs_seconds</tt>, three signals). The <tt>.npy</tt> is
|
|
||||||
<tt>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</tt> bytes. Read it back with
|
|
||||||
<tt>pd.DataFrame(np.load(path))</tt>.
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
<b>Sessions and settings</b>
|
|
||||||
|
|
||||||
udpscope stores named sessions — <i>configurations</i> — per source, so reopening a source gives
|
|
||||||
you the scope you closed, and one source can hold several arrangements for different experiments.
|
|
||||||
There is no global preferences file: the session <i>is</i> the settings.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
Nothing to open — the file is written as you work. Its path is in the About dialog, and the
|
|
||||||
directory is <tt>$XDG_CONFIG_HOME/udpscope</tt>, or <tt>~/.config/udpscope</tt> when that is
|
|
||||||
unset. Each source has a directory <tt><host>_<port>/</tt> holding one
|
|
||||||
<tt><name>.toml</tt> per configuration and an <tt>active</tt> file naming the one it is on.
|
|
||||||
|
|
||||||
<b>Configurations</b>
|
|
||||||
|
|
||||||
<b>Configurations…</b> in the menu lists the running source's configurations: click a row to open
|
|
||||||
it, <tt>×</tt> to delete it, or type a name and a note and press <i>Save this session</i> to keep
|
|
||||||
the current arrangement under a new one. Opening a configuration saves the current session first,
|
|
||||||
so switching never loses what it switches from.
|
|
||||||
|
|
||||||
The startup chooser (start with no <tt>--server</tt>) lists one row per configuration, with its
|
|
||||||
note and its settings, so two arrangements of one source are told apart before they open.
|
|
||||||
|
|
||||||
A configuration also carries the settings that can only be decided when the buffers are
|
|
||||||
allocated: the <b>buffer size</b> (<tt>--buffer-mb</tt>), relative time, and the packet capture's
|
|
||||||
ring size. Opening a configuration from the chooser applies all three; switching inside a running
|
|
||||||
app applies the rest and says in the status line that these take effect on the next start.
|
|
||||||
|
|
||||||
The chooser's form sets the first two for a new host, and its pencil button edits them on an
|
|
||||||
existing configuration — along with the name and the note. Renaming a configuration, or editing
|
|
||||||
the address it points at, moves it: the old file is removed rather than left behind.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• What a configuration persists: the time window, the rows and each row's slot signals, the
|
|
||||||
calibration, the trace colours, which signals are enabled, the math channels with their graphs,
|
|
||||||
the measurements pane — and the startup settings: buffer size, relative time and the packet
|
|
||||||
capture's ring size.
|
|
||||||
• Signal identity is the <i>name</i> throughout, because a new CONFIG renumbers the channels.
|
|
||||||
• A file written by a run against a source whose streamer was down would name no signals at all,
|
|
||||||
so such a run does <b>not</b> write: a configured session is never overwritten by a run that
|
|
||||||
configured nothing. (The chooser hides such a file for the same reason.)
|
|
||||||
• The multicast group and the interface live <i>inside</i> the file, because its name cannot
|
|
||||||
carry them. A file from an older build has neither key and loads as unicast on the default
|
|
||||||
route.
|
|
||||||
• The chooser lists only configurations that name at least one signal, which is what keeps it the
|
|
||||||
sessions you configured rather than every port you have ever tested against.
|
|
||||||
• Deleting a configuration is a supported way to start over; the chooser's <tt>×</tt> and the
|
|
||||||
dialog's do exactly that.
|
|
||||||
• A session from before configurations existed is imported once as the configuration
|
|
||||||
<tt>default</tt>, and the old file is kept beside the directory as
|
|
||||||
<tt><host>_<port>.toml.imported</tt>.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1, set a 2 s window, and save the
|
|
||||||
session as <tt>bench</tt> with a note. <tt>~/.config/udpscope/127.0.0.1_8000/bench.toml</tt> now
|
|
||||||
holds <tt>window_s=2 comment="…"</tt> with <tt>signals = ["sine", "ramp", "noise", ""]</tt>, and
|
|
||||||
the next run reports <tt>layout loaded from …/127.0.0.1_8000/bench.toml</tt> with those signals
|
|
||||||
already acquired.
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<b>Status bar and diagnostics</b>
|
|
||||||
|
|
||||||
The status bar is two tiers. The bar itself is one glance line — the state, the rate and the
|
|
||||||
window — and everything else is behind the ⓘ button at its right-hand end.
|
|
||||||
|
|
||||||
<b>How to reach it</b>
|
|
||||||
|
|
||||||
The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popover.
|
|
||||||
|
|
||||||
<b>Worth knowing</b>
|
|
||||||
|
|
||||||
• The badge is one glyph in four colours: green <i>alternating</i> lit and dim means data is
|
|
||||||
arriving, and a steady colour means it is not. A badge that never blinks is not a working badge.
|
|
||||||
Of the two steady colours, <i>grey</i> is no stream at all or a pipeline you stopped (the
|
|
||||||
tooltip says which), and <i>yellow</i> is a source that answered but has sent no configuration.
|
|
||||||
• The state field is one word from a fixed set: <tt>connecting</tt>, <tt>live</tt>,
|
|
||||||
<tt>paused</tt>, <tt>armed</tt>, <tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. Two
|
|
||||||
states add one field each: in trigger mode, how long ago the last event was; while paused, how
|
|
||||||
long you have been paused.
|
|
||||||
• <tt>connecting</tt> means there is no <i>signal list</i> yet — either no session or a session
|
|
||||||
with no configuration behind it. Every other word claims a signal list, so until one arrives
|
|
||||||
there is nothing for them to describe: nothing can be enabled and no data is owed.
|
|
||||||
• The paused figure is <i>wall</i> time, not stream time — the producer never stops while paused,
|
|
||||||
so it answers "how long have I been looking at this picture".
|
|
||||||
• Nothing on the bar may change width with the stream, because the bar sits directly above the
|
|
||||||
plot and a wider label would relayout it. That is why the rate collapses to one figure or a
|
|
||||||
range rather than listing per channel, and why an elapsed time steps to a coarser unit instead
|
|
||||||
of growing digits.
|
|
||||||
• <tt>gaps</tt> counts packets the receiver knows it missed. It is the number that matters: a
|
|
||||||
non-zero count means samples are absent from the buffer, not merely late.
|
|
||||||
• <tt>reconnects</tt> counts sessions re-established with the source. Over unicast a climbing
|
|
||||||
count usually means a second client is taking the source's single session.
|
|
||||||
• The starvation notice — <i>connected, but no data is arriving</i> — is that same situation seen
|
|
||||||
from the other side: the signal list is here, so something took the stream away. A source that
|
|
||||||
has sent <i>no</i> configuration gets a different notice, <i>trying to connect</i>, because
|
|
||||||
nothing was taken away — pointing the app at a port where nothing answers is that case, and the
|
|
||||||
usual cause is a streamer that is not running or is serving another port.
|
|
||||||
• <tt>stored</tt> versus <tt>wire</tt> in the popover is the decimation. They differ exactly
|
|
||||||
when the window is too long to store at full rate.
|
|
||||||
|
|
||||||
<b>Example</b>
|
|
||||||
|
|
||||||
The bar reads <tt>● live · 10 kS/s · 2 s window</tt>. Behind ⓘ:
|
|
||||||
<tt>Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3 channels</tt>,
|
|
||||||
<tt>Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire 10 kS/s)</tt>,
|
|
||||||
<tt>Plot 1 3 traces · 195000 samples</tt>, <tt>Reconnects 0</tt>.
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!--
|
|
||||||
SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
The help pages, compiled into the binary for the same reason the icons are: udpscope is
|
|
||||||
run straight out of the build tree and has no install step, so a search path would have
|
|
||||||
to be located at runtime and the failure mode of not locating it is a help window full
|
|
||||||
of missing pages.
|
|
||||||
-->
|
|
||||||
<gresources>
|
|
||||||
<gresource prefix="/org/udpscope/help">
|
|
||||||
<file>01-overview.txt</file>
|
|
||||||
<file>02-source.txt</file>
|
|
||||||
<file>03-signals.txt</file>
|
|
||||||
<file>04-layout.txt</file>
|
|
||||||
<file>05-window.txt</file>
|
|
||||||
<file>06-navigate.txt</file>
|
|
||||||
<file>07-pause.txt</file>
|
|
||||||
<file>08-trigger.txt</file>
|
|
||||||
<file>09-cursors.txt</file>
|
|
||||||
<file>10-measurements.txt</file>
|
|
||||||
<file>11-math.txt</file>
|
|
||||||
<file>12-export.txt</file>
|
|
||||||
<file>13-sessions.txt</file>
|
|
||||||
<file>14-status.txt</file>
|
|
||||||
|
|
||||||
<!-- The screenshots live with the user documentation (docs/); the resource aliases
|
|
||||||
them under `img/` so a help page can name one by resource path. The path is relative
|
|
||||||
to this manifest's directory. -->
|
|
||||||
<file alias="img/main-window.png">../../docs/img/main-window.png</file>
|
|
||||||
<file alias="img/trigger-capture.png">../../docs/img/trigger-capture.png</file>
|
|
||||||
<file alias="img/math-editor.png">../../docs/img/math-editor.png</file>
|
|
||||||
</gresource>
|
|
||||||
</gresources>
|
|
||||||
+140
-201
@@ -3,48 +3,36 @@
|
|||||||
|
|
||||||
#include <adwaita.h>
|
#include <adwaita.h>
|
||||||
|
|
||||||
#include <cstdio>
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
#include "build_info.hpp"
|
#include "build_info.hpp"
|
||||||
#include "layout_store.hpp"
|
#include "layout_store.hpp"
|
||||||
#include "shortcuts.hpp"
|
|
||||||
|
// Both come from CMakeLists: the document identifier is derived from the desktop file's
|
||||||
|
// basename (its desktop-file id), and the directory is where the Mallard set lives in the
|
||||||
|
// source tree, which is what the self-check scans. Yelp resolves `help:<identifier>` to
|
||||||
|
// `<datadir>/help/<lang>/<identifier>/` on its own (freedesktop Help System Specification),
|
||||||
|
// so the identifier and the installed directory have to name the same set -- which is why
|
||||||
|
// neither is written out again here.
|
||||||
|
static_assert(sizeof(UDPSCOPE_HELP_NAME) > 1, "UDPSCOPE_HELP_NAME must come from the build");
|
||||||
|
static_assert(sizeof(UDPSCOPE_HELP_DIR) > 1, "UDPSCOPE_HELP_DIR must come from the build");
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
namespace {
|
namespace {
|
||||||
|
|
||||||
/// Nulled by each window's own close handler, so a second open cannot present a dead widget.
|
/// Nulled by each dialog's own close handler, so a second open cannot present a dead widget.
|
||||||
/// One pointer per window, file-scope because there is one application.
|
/// One pointer per dialog, file-scope because there is one application. The help manual is no
|
||||||
///
|
/// longer one of these: it opens in a separate viewer process through the `help:` URI, and a
|
||||||
/// The help window and the shortcut overview are `GtkWindow`s. The About dialog is an
|
/// process with no window of ours has nothing to null.
|
||||||
/// `AdwDialog`, which is **not** a `GtkWindow`: it has no `::close-request`, so it clears on
|
|
||||||
/// `::closed` and closes with `adw_dialog_close()`.
|
|
||||||
GtkWidget* g_help = nullptr;
|
|
||||||
/// The help window is a `.ui` file too, so its builder, the label one page's markup goes into and the
|
|
||||||
/// column its screenshots go into are all kept beside the window's own pointer. All four are cleared by
|
|
||||||
/// the window's own close handler: the page switch reads them, and it cannot run after the window is
|
|
||||||
/// gone.
|
|
||||||
GtkBuilder* g_help_ui = nullptr;
|
|
||||||
GtkWidget* g_help_body = nullptr;
|
|
||||||
GtkWidget* g_help_shots = nullptr;
|
|
||||||
AdwDialog* g_about = nullptr;
|
AdwDialog* g_about = nullptr;
|
||||||
/// The overview is the one window here built from a `.ui` file, so its builder is held too: the window
|
/// The overview is built from a `.ui` file, so its builder is held too: the window belongs to
|
||||||
/// belongs to the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
|
/// the builder, and a *destroyed* `GtkWindow` cannot be presented again -- which is why the
|
||||||
/// builder is replaced on the next opening rather than reused.
|
/// builder is replaced on the next opening rather than reused.
|
||||||
GtkWidget* g_shortcuts = nullptr;
|
GtkWidget* g_shortcuts = nullptr;
|
||||||
GtkBuilder* g_shortcuts_ui = nullptr;
|
GtkBuilder* g_shortcuts_ui = nullptr;
|
||||||
|
|
||||||
/// The help window's own close handler, rather than the shared one: this window also owns the label a
|
|
||||||
/// page is written into and the column its screenshots are built in, and a page switch that ran after
|
|
||||||
/// the window was gone would read both.
|
|
||||||
gboolean on_help_close(GtkWindow* window, gpointer) {
|
|
||||||
(void)window;
|
|
||||||
g_help = nullptr;
|
|
||||||
g_help_body = nullptr;
|
|
||||||
g_help_shots = nullptr;
|
|
||||||
return FALSE; // let the default handler destroy it
|
|
||||||
}
|
|
||||||
|
|
||||||
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
gboolean on_window_close(GtkWindow* window, gpointer slot) {
|
||||||
(void)window;
|
(void)window;
|
||||||
*static_cast<GtkWidget**>(slot) = nullptr;
|
*static_cast<GtkWidget**>(slot) = nullptr;
|
||||||
@@ -58,100 +46,58 @@ void on_dialog_closed(AdwDialog* dialog, gpointer slot) {
|
|||||||
*static_cast<AdwDialog**>(slot) = nullptr;
|
*static_cast<AdwDialog**>(slot) = nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
struct HelpPage {
|
/// What an asynchronous launch needs to survive the call: the window it is attributed to (held,
|
||||||
const char* section; ///< the group heading in the sidebar
|
/// because the call outlives the stack frame that made it) and the caller's reporter, if it asked
|
||||||
const char* title; ///< the row's label and the page's heading
|
/// for one. One small struct rather than two `gpointer`s, because a callback that takes both through
|
||||||
const char* resource; ///< a path under /org/udpscope/help
|
/// one `user_data` still has to get them there.
|
||||||
/// The screenshots shown above the text, as resource paths and a null terminator. A page
|
struct HelpLaunch {
|
||||||
/// with none is the common case and the reason this is a list rather than one field: the
|
GtkWindow* parent = nullptr;
|
||||||
/// overview carries two, the window and a held capture.
|
std::function<void(bool, const std::string&)> done;
|
||||||
const char* const* images = nullptr;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Adding a page is one .txt file, one <file> line in the manifest and one row here.
|
/// The launch failed. Report it rather than leaving a click that silently did nothing -- and say
|
||||||
/// The order here is the order the sidebar shows; `section` groups consecutive rows.
|
/// *which* thing is missing: `gtk_uri_launcher_launch_finish()` reports that the URI could not be
|
||||||
/// How tall a page's screenshots are drawn. They are scaled to fit this *height* and centre
|
/// handed over, which is the handler's absence, while a document that is not installed is a
|
||||||
/// themselves in it, so a 1500x950 capture is readable without a horizontal scrollbar.
|
/// different failure the launcher cannot tell it from. `help_installed_path()` settles that from the
|
||||||
constexpr int kHelpShotHeight = 260;
|
/// file system, so a missing *document* is not reported as a missing *handler*: with the manual
|
||||||
|
/// installed the message names the viewer, and without it the message names the document, its
|
||||||
/// The screenshots each page carries, in the order it shows them.
|
/// expected path and the install command -- and a viewer as well, because that second possibility
|
||||||
constexpr const char* kOverviewShots[] = {"/org/udpscope/help/img/main-window.png",
|
/// cannot be ruled out from here.
|
||||||
"/org/udpscope/help/img/trigger-capture.png",
|
void on_help_launched(GObject* source, GAsyncResult* result, gpointer user) {
|
||||||
nullptr};
|
auto* launch = static_cast<HelpLaunch*>(user);
|
||||||
constexpr const char* kTriggerShots[] = {"/org/udpscope/help/img/trigger-capture.png",
|
|
||||||
nullptr};
|
|
||||||
constexpr const char* kMathShots[] = {"/org/udpscope/help/img/math-editor.png", nullptr};
|
|
||||||
|
|
||||||
constexpr HelpPage kPages[] = {
|
|
||||||
{"Getting started", "Overview", "/org/udpscope/help/01-overview.txt", kOverviewShots},
|
|
||||||
{"Getting started", "Choosing a source", "/org/udpscope/help/02-source.txt"},
|
|
||||||
{"Getting started", "Signals and calibration", "/org/udpscope/help/03-signals.txt"},
|
|
||||||
{"Getting started", "Rows, layout and drag & drop", "/org/udpscope/help/04-layout.txt"},
|
|
||||||
{"Viewing", "Time window", "/org/udpscope/help/05-window.txt"},
|
|
||||||
{"Viewing", "Navigating the plot", "/org/udpscope/help/06-navigate.txt"},
|
|
||||||
{"Viewing", "Pause and play", "/org/udpscope/help/07-pause.txt"},
|
|
||||||
{"Viewing", "Trigger", "/org/udpscope/help/08-trigger.txt", kTriggerShots},
|
|
||||||
{"Analysis", "Cursors", "/org/udpscope/help/09-cursors.txt"},
|
|
||||||
{"Analysis", "Measurements", "/org/udpscope/help/10-measurements.txt"},
|
|
||||||
{"Analysis", "Math channels", "/org/udpscope/help/11-math.txt", kMathShots},
|
|
||||||
{"Output and state", "Saving and export", "/org/udpscope/help/12-export.txt"},
|
|
||||||
{"Output and state", "Sessions and settings", "/org/udpscope/help/13-sessions.txt"},
|
|
||||||
{"Output and state", "Status bar and diagnostics", "/org/udpscope/help/14-status.txt"},
|
|
||||||
};
|
|
||||||
constexpr int kPageCount = static_cast<int>(sizeof(kPages) / sizeof(kPages[0]));
|
|
||||||
|
|
||||||
/// The page's markup, or a one-line stand-in. `ok` reports whether the real body loaded
|
|
||||||
/// *and* parsed: a missing resource and markup Pango rejects both render the stand-in,
|
|
||||||
/// because a help window that fails to open is worse than one page that says so.
|
|
||||||
std::string load_page(const HelpPage& page, bool* ok) {
|
|
||||||
*ok = false;
|
|
||||||
GError* error = nullptr;
|
GError* error = nullptr;
|
||||||
GBytes* bytes = g_resources_lookup_data(page.resource, G_RESOURCE_LOOKUP_FLAGS_NONE,
|
const bool ok = gtk_uri_launcher_launch_finish(GTK_URI_LAUNCHER(source), result, &error);
|
||||||
&error);
|
if (!ok) {
|
||||||
if (bytes == nullptr) {
|
const std::string installed = help_installed_path();
|
||||||
if (error != nullptr) g_error_free(error);
|
GtkAlertDialog* alert = gtk_alert_dialog_new("Could not open the help manual");
|
||||||
return "<i>This page is missing from the build.</i>";
|
std::string detail = error != nullptr ? error->message : std::string("the launch failed");
|
||||||
}
|
detail += "\n\n";
|
||||||
gsize size = 0;
|
if (!installed.empty()) {
|
||||||
const char* data = static_cast<const char*>(g_bytes_get_data(bytes, &size));
|
detail += "The manual is installed at " + installed +
|
||||||
std::string body(data, size);
|
", so what is missing is a viewer for the `help:` URI it is opened with: install "
|
||||||
g_bytes_unref(bytes);
|
"one (for example `yelp`) or make it the desktop's default for that scheme.";
|
||||||
|
} else {
|
||||||
if (!pango_parse_markup(body.c_str(), -1, 0, nullptr, nullptr, nullptr, &error)) {
|
// The system-wide install is named *first* on purpose: a set under the user's data
|
||||||
if (error != nullptr) g_error_free(error);
|
// directory is one a sandboxed viewer (Yelp) cannot read its figures from -- the pages
|
||||||
return "<i>This page could not be displayed: its markup is malformed.</i>";
|
// open without their images -- so the no-root route carries that caveat rather than
|
||||||
}
|
// leading. The message is kept no longer than the one it replaces; do the same on edit.
|
||||||
*ok = true;
|
detail += "The manual does not look installed: nothing named \"help:" UDPSCOPE_HELP_NAME
|
||||||
return body;
|
"\" was found where `help:` resolves. Install it system-wide:\n\n"
|
||||||
}
|
" cmake -DCMAKE_INSTALL_PREFIX=/usr .. && sudo cmake --build build --target install-help\n\n"
|
||||||
|
"That writes where the viewer looks. The no-root `install-help-user` writes under "
|
||||||
void on_help_row_selected(GtkListBox*, GtkListBoxRow* row, gpointer) {
|
"your data directory instead: a sandboxed viewer shows those pages without their "
|
||||||
if (row == nullptr || g_help_body == nullptr) return;
|
"figures. A `help:` viewer (for example `yelp`) is needed as well.";
|
||||||
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) {
|
gtk_alert_dialog_set_detail(alert, detail.c_str());
|
||||||
GtkWidget* shot = gtk_picture_new_for_resource(*img);
|
gtk_alert_dialog_show(alert, launch->parent);
|
||||||
gtk_picture_set_can_shrink(GTK_PICTURE(shot), TRUE);
|
g_object_unref(alert);
|
||||||
// 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 (launch->done) {
|
||||||
|
launch->done(ok, ok || error == nullptr ? std::string() : std::string(error->message));
|
||||||
|
}
|
||||||
|
if (launch->parent != nullptr) g_object_unref(launch->parent);
|
||||||
|
delete launch;
|
||||||
|
if (error != nullptr) g_error_free(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
@@ -195,86 +141,81 @@ const char* about_icon_name() {
|
|||||||
return adw_about_dialog_get_application_icon(ADW_ABOUT_DIALOG(g_about));
|
return adw_about_dialog_get_application_icon(ADW_ABOUT_DIALOG(g_about));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string help_installed_path() {
|
||||||
|
// The freedesktop Help System Specification's own search order: `$XDG_DATA_HOME` first, then every
|
||||||
|
// `$XDG_DATA_DIRS` entry in order. Yelp resolves `help:<id>` the same way, so this is a reading of
|
||||||
|
// where the document *would* be found rather than a second, private convention.
|
||||||
|
std::vector<std::string> roots;
|
||||||
|
roots.emplace_back(g_get_user_data_dir() != nullptr ? g_get_user_data_dir() : "");
|
||||||
|
const char* const* dirs = g_get_system_data_dirs();
|
||||||
|
for (int i = 0; dirs != nullptr && dirs[i] != nullptr; ++i) roots.emplace_back(dirs[i]);
|
||||||
|
for (const std::string& root : roots) {
|
||||||
|
if (root.empty()) continue;
|
||||||
|
const std::string base = root + "/help";
|
||||||
|
GError* error = nullptr;
|
||||||
|
GDir* dir = g_dir_open(base.c_str(), 0, &error);
|
||||||
|
if (dir == nullptr) {
|
||||||
|
if (error != nullptr) g_error_free(error);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// `<lang>` is a directory of its own (`C`, `en`, `it`, ...), so the document is looked for one
|
||||||
|
// level below the `help` root rather than straight under it.
|
||||||
|
while (const char* lang = g_dir_read_name(dir)) {
|
||||||
|
const std::string candidate = base + "/" + lang + "/" UDPSCOPE_HELP_NAME;
|
||||||
|
if (g_file_test((candidate + "/index.page").c_str(), G_FILE_TEST_IS_REGULAR)) {
|
||||||
|
g_dir_close(dir);
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
g_dir_close(dir);
|
||||||
|
}
|
||||||
|
return std::string();
|
||||||
|
}
|
||||||
|
|
||||||
HelpReport help_self_check() {
|
HelpReport help_self_check() {
|
||||||
HelpReport report;
|
HelpReport report;
|
||||||
report.pages = kPageCount;
|
|
||||||
for (const HelpPage& page : kPages) {
|
// The manual is a set of files on disk now, so the reading is a file-system one: a missing
|
||||||
bool ok = false;
|
// directory leaves `topics=0 index=false`, which is a manual that is not where the build
|
||||||
const std::string body = load_page(page, &ok);
|
// says it is -- and Yelp would show nothing for `help:<identifier>`.
|
||||||
if (ok && !body.empty()) ++report.parsed;
|
GError* error = nullptr;
|
||||||
if (report.shortest_title.empty() || body.size() < report.shortest_bytes) {
|
GDir* dir = g_dir_open(UDPSCOPE_HELP_DIR, 0, &error);
|
||||||
report.shortest_title = page.title;
|
if (dir == nullptr) {
|
||||||
report.shortest_bytes = body.size();
|
if (error != nullptr) g_error_free(error);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
while (const char* name = g_dir_read_name(dir)) {
|
||||||
|
if (!g_str_has_suffix(name, ".page")) continue;
|
||||||
|
if (g_strcmp0(name, "index.page") == 0) report.index = true;
|
||||||
|
else ++report.topics;
|
||||||
|
}
|
||||||
|
g_dir_close(dir);
|
||||||
|
|
||||||
|
// The figures the pages reference, counted for the same reason: an image that went missing
|
||||||
|
// is a broken figure in the viewer rather than a failed build.
|
||||||
|
const std::string figures = std::string(UDPSCOPE_HELP_DIR) + "/figures";
|
||||||
|
if (GDir* figdir = g_dir_open(figures.c_str(), 0, &error)) {
|
||||||
|
while (const char* name = g_dir_read_name(figdir)) {
|
||||||
|
if (g_str_has_suffix(name, ".png") || g_str_has_suffix(name, ".jpg")) ++report.figures;
|
||||||
}
|
}
|
||||||
|
g_dir_close(figdir);
|
||||||
|
} else if (error != nullptr) {
|
||||||
|
g_error_free(error);
|
||||||
}
|
}
|
||||||
return report;
|
return report;
|
||||||
}
|
}
|
||||||
|
|
||||||
void show_help(GtkWindow* parent) {
|
void show_help(GtkWindow* parent, std::function<void(bool, const std::string&)> done) {
|
||||||
if (g_help != nullptr) {
|
// The freedesktop `help:` URI, handed to whatever the desktop registered for it -- Yelp on
|
||||||
gtk_window_present(GTK_WINDOW(g_help));
|
// GNOME. The launch is asynchronous, so both the parent and the reporter are carried across it in
|
||||||
return;
|
// a heap `HelpLaunch` and released in the callback; `parent` may legitimately be nullptr (the
|
||||||
}
|
// chooser runs before any window exists).
|
||||||
|
auto* launch = new HelpLaunch;
|
||||||
// A builder per opening, as with every other `.ui` here: the window belongs to its object list and
|
launch->parent = parent != nullptr ? GTK_WINDOW(g_object_ref(parent)) : nullptr;
|
||||||
// `close-request` destroys the window, so the resource is parsed again rather than a dead window
|
launch->done = std::move(done);
|
||||||
// fetched. This is where the previous one goes, and it is safe because that window has been closed
|
GtkUriLauncher* launcher = gtk_uri_launcher_new("help:" UDPSCOPE_HELP_NAME);
|
||||||
// for as long as it takes someone to ask for help again.
|
gtk_uri_launcher_launch(launcher, parent, nullptr, on_help_launched, launch);
|
||||||
g_clear_object(&g_help_ui);
|
g_object_unref(launcher);
|
||||||
g_help_ui = gtk_builder_new_from_resource("/org/udpscope/ui/help.ui");
|
|
||||||
if (g_help_ui == nullptr) return;
|
|
||||||
GObject* window = gtk_builder_get_object(g_help_ui, "help_window");
|
|
||||||
GObject* list = gtk_builder_get_object(g_help_ui, "page_list");
|
|
||||||
GObject* body = gtk_builder_get_object(g_help_ui, "body");
|
|
||||||
GObject* shots = gtk_builder_get_object(g_help_ui, "shot_box");
|
|
||||||
// A missing id is the one mistake the build and `GtkBuilder` both miss; every reader below would be a
|
|
||||||
// null widget on the first page switch.
|
|
||||||
if (window == nullptr || list == nullptr || body == nullptr || shots == nullptr) return;
|
|
||||||
g_help = GTK_WIDGET(window);
|
|
||||||
g_help_body = GTK_WIDGET(body);
|
|
||||||
g_help_shots = GTK_WIDGET(shots);
|
|
||||||
gtk_window_set_transient_for(GTK_WINDOW(g_help), parent);
|
|
||||||
|
|
||||||
// The rows are the pages, which is data and not template: a heading row per section, then one row per
|
|
||||||
// page carrying its own index. A template cannot know the fourteen pages, and a sectioned list is not
|
|
||||||
// what a GtkListView with one item factory can draw.
|
|
||||||
const char* current_section = nullptr;
|
|
||||||
for (int i = 0; i < kPageCount; ++i) {
|
|
||||||
if (current_section == nullptr || std::string(current_section) != kPages[i].section) {
|
|
||||||
current_section = kPages[i].section;
|
|
||||||
GtkWidget* heading = gtk_label_new(nullptr);
|
|
||||||
char markup[128];
|
|
||||||
std::snprintf(markup, sizeof(markup), "<b><small>%s</small></b>", current_section);
|
|
||||||
gtk_label_set_markup(GTK_LABEL(heading), markup);
|
|
||||||
gtk_label_set_xalign(GTK_LABEL(heading), 0.0f);
|
|
||||||
gtk_widget_set_margin_start(heading, 10);
|
|
||||||
gtk_widget_set_margin_top(heading, 10);
|
|
||||||
gtk_widget_set_margin_bottom(heading, 2);
|
|
||||||
GtkWidget* heading_row = gtk_list_box_row_new();
|
|
||||||
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(heading_row), FALSE);
|
|
||||||
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(heading_row), FALSE);
|
|
||||||
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(heading_row), heading);
|
|
||||||
gtk_list_box_append(GTK_LIST_BOX(list), heading_row);
|
|
||||||
}
|
|
||||||
GtkWidget* label = gtk_label_new(kPages[i].title);
|
|
||||||
gtk_label_set_xalign(GTK_LABEL(label), 0.0f);
|
|
||||||
gtk_widget_set_margin_start(label, 18);
|
|
||||||
gtk_widget_set_margin_end(label, 10);
|
|
||||||
gtk_widget_set_margin_top(label, 4);
|
|
||||||
gtk_widget_set_margin_bottom(label, 4);
|
|
||||||
GtkWidget* row = gtk_list_box_row_new();
|
|
||||||
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), label);
|
|
||||||
g_object_set_data(G_OBJECT(row), "help-page", GINT_TO_POINTER(i));
|
|
||||||
gtk_list_box_append(GTK_LIST_BOX(list), row);
|
|
||||||
}
|
|
||||||
g_signal_connect(list, "row-selected", G_CALLBACK(on_help_row_selected), nullptr);
|
|
||||||
|
|
||||||
// Select the first page so the window never opens blank.
|
|
||||||
GtkListBoxRow* first = gtk_list_box_get_row_at_index(GTK_LIST_BOX(list), 1);
|
|
||||||
if (first != nullptr) gtk_list_box_select_row(GTK_LIST_BOX(list), first);
|
|
||||||
|
|
||||||
g_signal_connect(g_help, "close-request", G_CALLBACK(on_help_close), &g_help);
|
|
||||||
gtk_window_present(GTK_WINDOW(g_help));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void show_shortcuts(GtkWindow* parent) {
|
void show_shortcuts(GtkWindow* parent) {
|
||||||
@@ -284,17 +225,16 @@ void show_shortcuts(GtkWindow* parent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// A `GtkShortcutsWindow` from `src/ui/shortcuts.ui` -- gedit's arrangement, and GTK's own: one
|
// A `GtkShortcutsWindow` from `src/ui/shortcuts.ui` -- gedit's arrangement, and GTK's own: one
|
||||||
// section per page, with the sidebar and the search box drawn by the window itself.
|
// section per page, with the sidebar and the search box drawn by the window itself. A UI file
|
||||||
//
|
// rather than widget construction because of the mouse rows: an accelerator cannot spell "Ctrl
|
||||||
// A UI file rather than widget construction because of the mouse rows. An accelerator cannot spell
|
// and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement -- has no icon
|
||||||
// "Ctrl and drag", and libadwaita's `AdwShortcutsItem` -- the modern replacement for this widget --
|
// property, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic gesture type
|
||||||
// has no icon property at all, so its cell reads "No Shortcut". `GtkShortcutsShortcut`'s one generic
|
// draws the `icon` the row names. (The widget is deprecated since 4.20, but this uses none of
|
||||||
// gesture type draws the `icon` the row names, which is what puts a mouse glyph where the keycaps
|
// its functions: the file is loaded as data and presented as a `GtkWindow`.)
|
||||||
// would be. (The widget is deprecated since 4.20, but this uses none of its functions: the file is
|
|
||||||
// loaded as data and presented as a `GtkWindow`, so the build stays at its four warnings.)
|
|
||||||
//
|
//
|
||||||
// A builder per opening, not one reused: the window belongs to the builder and `close-request`
|
// A builder per opening, not one reused: the window belongs to the builder and `close-request`
|
||||||
// destroys the window, so the resource is parsed again rather than an already-dead window fetched.
|
// destroys the window, so the resource is parsed again rather than an already-dead window
|
||||||
|
// fetched.
|
||||||
g_clear_object(&g_shortcuts_ui);
|
g_clear_object(&g_shortcuts_ui);
|
||||||
g_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui");
|
g_shortcuts_ui = gtk_builder_new_from_resource("/org/udpscope/ui/shortcuts.ui");
|
||||||
if (g_shortcuts_ui == nullptr) return;
|
if (g_shortcuts_ui == nullptr) return;
|
||||||
@@ -307,7 +247,6 @@ void show_shortcuts(GtkWindow* parent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void close_help_windows() {
|
void close_help_windows() {
|
||||||
if (g_help != nullptr) gtk_window_close(GTK_WINDOW(g_help));
|
|
||||||
if (g_about != nullptr) adw_dialog_close(g_about);
|
if (g_about != nullptr) adw_dialog_close(g_about);
|
||||||
if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts));
|
if (g_shortcuts != nullptr) gtk_window_close(GTK_WINDOW(g_shortcuts));
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-15
@@ -4,6 +4,7 @@
|
|||||||
#include <gtk/gtk.h>
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
#include <cstddef>
|
#include <cstddef>
|
||||||
|
#include <functional>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
@@ -18,20 +19,39 @@ void show_about(GtkWindow* parent, const std::string& session_path);
|
|||||||
/// so the dialog is asserted to *show* the app icon rather than merely to open.
|
/// so the dialog is asserted to *show* the app icon rather than merely to open.
|
||||||
const char* about_icon_name();
|
const char* about_icon_name();
|
||||||
|
|
||||||
/// The paged help window. Modal on nothing — it is a reference, so it stays usable while
|
/// Opens the installed Mallard manual in the desktop help viewer — Yelp, through the
|
||||||
/// the scope runs. Already open: presented.
|
/// `help:` URI the freedesktop Help System Specification defines — rather than drawing a
|
||||||
void show_help(GtkWindow* parent);
|
/// 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()`.
|
||||||
|
///
|
||||||
|
/// `done`, when the caller gives one, is called with the *launch*'s outcome: whether the
|
||||||
|
/// `help:` URI was handed to a viewer, and the error text when it was not. It says nothing
|
||||||
|
/// about the viewer itself — that process may fail for its own reasons and this side cannot
|
||||||
|
/// see them — so it is the self-check's reading of the path and not a promise that a window
|
||||||
|
/// appeared. The failure alert is shown either way; the callback is an extra, not instead.
|
||||||
|
void show_help(GtkWindow* parent, std::function<void(bool ok, const std::string& error)> done = {});
|
||||||
|
|
||||||
/// What UDPSCOPE_PROBE_HELP_MS reports. `parsed` counts pages whose markup
|
/// Where `help:<id>` resolves to on this system: the first of `$XDG_DATA_HOME/help/<lang>/<id>/`
|
||||||
/// pango_parse_markup() accepted and whose body is non-empty; it must equal `pages`.
|
/// and `$XDG_DATA_DIRS/help/<lang>/<id>/` that holds an `index.page`, or an empty string when no
|
||||||
|
/// such directory does. This is the specification's own resolution, and the distinction a launch
|
||||||
|
/// failure turns on: a missing *document* (install it) and a missing *handler* (install a viewer)
|
||||||
|
/// are different problems with different fixes, and only the file system can tell them apart.
|
||||||
|
std::string help_installed_path();
|
||||||
|
|
||||||
|
/// What UDPSCOPE_PROBE_HELP_MS reports about the manual *in the tree*. `topics` is the
|
||||||
|
/// number of `<id>.page` files other than `index.page`, and `index` says whether
|
||||||
|
/// `index.page` is present: the manual is now a set of files on disk (installed by the
|
||||||
|
/// `install()` rule, and read by Yelp), not a GResource in the binary, so the self-check
|
||||||
|
/// asserts the set is *there* and must be read as a file-system finding — `topics=0
|
||||||
|
/// index=0` means the manual is missing from `UDPSCOPE_HELP_DIR`.
|
||||||
struct HelpReport {
|
struct HelpReport {
|
||||||
int pages = 0;
|
int topics = 0;
|
||||||
int parsed = 0;
|
bool index = false;
|
||||||
std::string shortest_title;
|
int figures = 0;
|
||||||
std::size_t shortest_bytes = 0;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Loads and validates every page without building a window. Safe to call headless.
|
/// Scans the manual's directory without opening a viewer. Safe to call headless.
|
||||||
HelpReport help_self_check();
|
HelpReport help_self_check();
|
||||||
|
|
||||||
/// The shortcut overview: the application accelerators and the plot's own interactions,
|
/// The shortcut overview: the application accelerators and the plot's own interactions,
|
||||||
@@ -39,11 +59,11 @@ HelpReport help_self_check();
|
|||||||
/// deprecated as of GTK 4.18 and this project builds warning-free.
|
/// deprecated as of GTK 4.18 and this project builds warning-free.
|
||||||
void show_shortcuts(GtkWindow* parent);
|
void show_shortcuts(GtkWindow* parent);
|
||||||
|
|
||||||
/// Closes whichever of the three windows are open. One of them is a GtkWindow -- the help
|
/// Closes whichever of the two dialogs are open. Both are `AdwDialog`s, which are not
|
||||||
/// window, closed with gtk_window_close(), which emits close-request so its handler runs. The
|
/// `GtkWindow`s: they close with `adw_dialog_close()`, which emits `::closed`, and the
|
||||||
/// other two are AdwDialogs, which are not GtkWindows: they close with adw_dialog_close(),
|
/// file-scope pointer is nulled by the handler that prevents a second open presenting a dead
|
||||||
/// which emits ::closed. Either way the file-scope pointer is nulled by the handler, which is
|
/// widget. There is no help window left to close -- the manual opens in a separate viewer
|
||||||
/// what prevents a second open presenting a dead widget. Used by the probe.
|
/// process. Used by the probe.
|
||||||
void close_help_windows();
|
void close_help_windows();
|
||||||
|
|
||||||
} // namespace udpscope
|
} // namespace udpscope
|
||||||
@@ -476,6 +476,48 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
return v;
|
return v;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// One `trigger_*` key, by its name after the prefix. Text for the two enumerations and for the
|
||||||
|
/// signal, numbers for the rest; `value` arrives unquoted.
|
||||||
|
const auto trigger_key = [](LayoutState::Trigger& t, const std::string& k, const std::string& v) {
|
||||||
|
const auto num = [&](double& out) {
|
||||||
|
try {
|
||||||
|
out = std::stod(v);
|
||||||
|
} catch (...) {
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (k == "enabled") {
|
||||||
|
t.enabled = v == "true";
|
||||||
|
} else if (k == "signal") {
|
||||||
|
t.channel = v;
|
||||||
|
} else if (k == "type") {
|
||||||
|
t.type = v;
|
||||||
|
} else if (k == "edge") {
|
||||||
|
t.edge = v;
|
||||||
|
} else if (k == "cmp") {
|
||||||
|
t.cmp = v;
|
||||||
|
} else if (k == "mode") {
|
||||||
|
t.mode = v;
|
||||||
|
} else if (k == "level") {
|
||||||
|
num(t.level);
|
||||||
|
t.level_given = true;
|
||||||
|
} else if (k == "level2") {
|
||||||
|
num(t.level2);
|
||||||
|
} else if (k == "width") {
|
||||||
|
num(t.width_s);
|
||||||
|
} else if (k == "width2") {
|
||||||
|
num(t.width2_s);
|
||||||
|
} else if (k == "timeout") {
|
||||||
|
num(t.timeout_s);
|
||||||
|
} else if (k == "pre") {
|
||||||
|
num(t.pretrigger_pct);
|
||||||
|
} else if (k == "holdoff") {
|
||||||
|
num(t.holdoff_s);
|
||||||
|
} else if (k == "band") {
|
||||||
|
num(t.noise_band);
|
||||||
|
} else if (k == "hf") {
|
||||||
|
num(t.hf_fc);
|
||||||
|
}
|
||||||
|
};
|
||||||
if (section == Section::Top) {
|
if (section == Section::Top) {
|
||||||
if (key == "window_s") {
|
if (key == "window_s") {
|
||||||
try {
|
try {
|
||||||
@@ -498,6 +540,8 @@ bool load_layout(const std::string& path, LayoutState& out) {
|
|||||||
st.measure_stats = value == "true";
|
st.measure_stats = value == "true";
|
||||||
} else if (key == "side_tab") {
|
} else if (key == "side_tab") {
|
||||||
st.side_tab = strip_quote(value);
|
st.side_tab = strip_quote(value);
|
||||||
|
} else if (key.rfind("trigger_", 0) == 0) {
|
||||||
|
trigger_key(st.trigger, key.substr(8), strip_quote(value));
|
||||||
} else if (key == "multicast") {
|
} else if (key == "multicast") {
|
||||||
st.multicast = strip_quote(value);
|
st.multicast = strip_quote(value);
|
||||||
} else if (key == "interface") {
|
} else if (key == "interface") {
|
||||||
@@ -648,6 +692,24 @@ bool save_layout(const std::string& path, const LayoutState& state) {
|
|||||||
}
|
}
|
||||||
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
|
o << "measure_stats = " << (state.measure_stats ? "true" : "false") << "\n";
|
||||||
o << "side_tab = \"" << state.side_tab << "\"\n";
|
o << "side_tab = \"" << state.side_tab << "\"\n";
|
||||||
|
// The trigger, settings and state both. Written even when it is off: that is the point of it.
|
||||||
|
o << "trigger_enabled = " << (state.trigger.enabled ? "true" : "false") << "\n";
|
||||||
|
if (!state.trigger.channel.empty()) {
|
||||||
|
o << "trigger_signal = " << toml_escape(state.trigger.channel) << "\n";
|
||||||
|
}
|
||||||
|
o << "trigger_type = " << toml_escape(state.trigger.type) << "\n";
|
||||||
|
o << "trigger_edge = " << toml_escape(state.trigger.edge) << "\n";
|
||||||
|
o << "trigger_cmp = " << toml_escape(state.trigger.cmp) << "\n";
|
||||||
|
o << "trigger_mode = " << toml_escape(state.trigger.mode) << "\n";
|
||||||
|
o << "trigger_level = " << state.trigger.level << "\n";
|
||||||
|
o << "trigger_level2 = " << state.trigger.level2 << "\n";
|
||||||
|
o << "trigger_width = " << state.trigger.width_s << "\n";
|
||||||
|
o << "trigger_width2 = " << state.trigger.width2_s << "\n";
|
||||||
|
o << "trigger_timeout = " << state.trigger.timeout_s << "\n";
|
||||||
|
o << "trigger_pre = " << state.trigger.pretrigger_pct << "\n";
|
||||||
|
o << "trigger_holdoff = " << state.trigger.holdoff_s << "\n";
|
||||||
|
o << "trigger_band = " << state.trigger.noise_band << "\n";
|
||||||
|
o << "trigger_hf = " << state.trigger.hf_fc << "\n";
|
||||||
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n";
|
if (!state.comment.empty()) o << "comment = " << toml_escape(state.comment) << "\n";
|
||||||
if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n";
|
if (state.buffer_mb > 0.0) o << "buffer_mb = " << state.buffer_mb << "\n";
|
||||||
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
|
o << "relative_time = " << (state.relative_time ? "true" : "false") << "\n";
|
||||||
|
|||||||
@@ -73,6 +73,36 @@ struct LayoutState {
|
|||||||
/// The measurements pane, by signal *name* and with the type and options a row was built with.
|
/// The measurements pane, by signal *name* and with the type and options a row was built with.
|
||||||
std::vector<Measure> measures;
|
std::vector<Measure> measures;
|
||||||
|
|
||||||
|
/// The trigger: its settings *and* whether trigger mode was on. Both, and the settings even while
|
||||||
|
/// it is off, because a trigger set up once and switched off is still the trigger you come back to
|
||||||
|
/// -- and the pane's locked view is built from exactly this.
|
||||||
|
///
|
||||||
|
/// The signal and the two enumerations are stored as *text*: the signal by name, because every
|
||||||
|
/// rebuild renumbers the channels (the model's own rule), and the shapes by the words the panel's
|
||||||
|
/// dropdowns show, because an index would tie this file to a declaration order -- which the math
|
||||||
|
/// channel's op index already taught this store once.
|
||||||
|
struct Trigger {
|
||||||
|
bool enabled = false;
|
||||||
|
std::string channel; ///< the acquired channel's name; empty is "none chosen"
|
||||||
|
std::string type = "edge"; ///< edge | pulse width | window | dropout
|
||||||
|
std::string edge = "rising"; ///< rising | falling | both
|
||||||
|
std::string cmp = "greater"; ///< pulse width: `<` | `>` | within | outside
|
||||||
|
std::string mode = "normal"; ///< normal | single
|
||||||
|
double level = 0.0;
|
||||||
|
double level2 = 0.0; ///< window: the band's other edge
|
||||||
|
double width_s = 1.0e-3;
|
||||||
|
double width2_s = 2.0e-3;
|
||||||
|
double timeout_s = 1.0e-3;
|
||||||
|
double pretrigger_pct = 50.0;
|
||||||
|
double holdoff_s = 0.010;
|
||||||
|
double noise_band = 0.0;
|
||||||
|
double hf_fc = 0.0;
|
||||||
|
/// Whether the file said anything about the level. A session that did keeps it; one that did not
|
||||||
|
/// leaves the panel's own "follow the channel's range" default alone.
|
||||||
|
bool level_given = false;
|
||||||
|
};
|
||||||
|
Trigger trigger;
|
||||||
|
|
||||||
/// Free text the user attached to this configuration: what it is for, what to look at first.
|
/// Free text the user attached to this configuration: what it is for, what to look at first.
|
||||||
/// A configuration is a *named* session, and the name alone rarely says what makes it different
|
/// A configuration is a *named* session, and the name alone rarely says what makes it different
|
||||||
/// from its neighbour.
|
/// from its neighbour.
|
||||||
|
|||||||
+159
-11
@@ -32,6 +32,23 @@ void enter_side(MeasureLevel& acc, std::size_t e, double t) {
|
|||||||
// setting now, and a second copy of the number here would be a second thing to keep in step.
|
// setting now, and a second copy of the number here would be a second thing to keep in step.
|
||||||
constexpr double kPi = 3.14159265358979323846;
|
constexpr double kPi = 3.14159265358979323846;
|
||||||
|
|
||||||
|
/// The flat level's band, as a half-width in shares of the window's peak-to-peak. 5 % is *under*
|
||||||
|
/// the overshoot a band-limited edge rings to -- Gibbs, about 9 % of the swing at the harmonic
|
||||||
|
/// counts the mock's `square` uses -- so the ringing's excursion falls outside the densest band
|
||||||
|
/// while a plateau's own ripple, a few percent, stays inside it. Measured on the mock's 30-harmonic
|
||||||
|
/// square at 1.3 MS/s: the flat top reads 1.000000 with this band, 1.003573 at 3 %, 1.003495 at
|
||||||
|
/// 10 % -- the wider bands reach the ringing again. A share and not an absolute width: the band is
|
||||||
|
/// the same quantity `threshold_pct` and the hysteresis are measured in.
|
||||||
|
constexpr double kFlatBandShare = 0.05;
|
||||||
|
|
||||||
|
/// Which bin of a side is the level: the *outermost* one holding at least this share of the
|
||||||
|
/// densest bin's buckets. One rule for both kinds of waveform. Where a plateau exists it is by
|
||||||
|
/// far the densest band and the rule cannot reach past it. Where none exists -- a sine, a ramp --
|
||||||
|
/// the signal dwells in every band it visits, the whole side qualifies, and the outermost is the
|
||||||
|
/// honest answer: the level is then the top of what the window reaches, near the peak, rather
|
||||||
|
/// than a bin of an arbitrary mid-range density that a shifted window would move.
|
||||||
|
constexpr double kFlatTieShare = 0.5;
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct, double noise_frac) {
|
void measure_set_level(MeasureLevel& acc, const MeasureStats& s, double pct, double noise_frac) {
|
||||||
@@ -116,14 +133,29 @@ void measure_scan(const MeasureChunk& chunk, MeasureStats& s, MeasureFilter& fil
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& s) {
|
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& s,
|
||||||
|
bool flat_hist) {
|
||||||
double sum = 0.0;
|
double sum = 0.0;
|
||||||
std::size_t finite = 0;
|
std::size_t finite = 0;
|
||||||
|
// The *buckets'* own range, which is not the window's: the window's partial edge buckets are
|
||||||
|
// read raw and are not in this list. Only the non-empty buckets: an all-non-finite bucket is
|
||||||
|
// stored as an empty aggregate and its `lo`/`hi` say nothing.
|
||||||
|
double blo = 0.0;
|
||||||
|
double bhi = 0.0;
|
||||||
|
bool any = false;
|
||||||
for (std::size_t b = 0; b < count; ++b) {
|
for (std::size_t b = 0; b < count; ++b) {
|
||||||
const MeasureBucket& k = buckets[b];
|
const MeasureBucket& k = buckets[b];
|
||||||
// A bucket of nothing (every sample non-finite) is stored as an empty aggregate, and the raw
|
// A bucket of nothing (every sample non-finite) is stored as an empty aggregate, and the raw
|
||||||
// walk skips exactly those samples -- so it contributes nothing here either.
|
// walk skips exactly those samples -- so it contributes nothing here either.
|
||||||
if (k.count == 0) continue;
|
if (k.count == 0) continue;
|
||||||
|
if (!any) {
|
||||||
|
blo = k.lo;
|
||||||
|
bhi = k.hi;
|
||||||
|
any = true;
|
||||||
|
} else {
|
||||||
|
if (k.lo < blo) blo = k.lo;
|
||||||
|
if (k.hi > bhi) bhi = k.hi;
|
||||||
|
}
|
||||||
sum += k.sum;
|
sum += k.sum;
|
||||||
finite += k.count;
|
finite += k.count;
|
||||||
if (!s.valid) {
|
if (!s.valid) {
|
||||||
@@ -147,6 +179,27 @@ void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, Measu
|
|||||||
}
|
}
|
||||||
s.sum += sum;
|
s.sum += sum;
|
||||||
s.count += finite;
|
s.count += finite;
|
||||||
|
if (!flat_hist || !any || !(bhi > blo)) return;
|
||||||
|
// One observation per bucket, at its midpoint, carrying that bucket's *exact* sum and finite
|
||||||
|
// count -- so the level the caller finally reports is a mean of real samples and not a bin
|
||||||
|
// centre. Weighting by the bucket (and not by its 64 samples) is what keeps the flat regions
|
||||||
|
// and the ringing on the same footing: the question is how much of the *waveform* dwells in a
|
||||||
|
// band, and a bucket is a span of it. A range that is degenerate (every bucket the same value)
|
||||||
|
// has no band to pick and leaves `folded` at 0, which is the caller's "no histogram".
|
||||||
|
s.flat.lo = blo;
|
||||||
|
s.flat.hi = bhi;
|
||||||
|
const double scale = static_cast<double>(MeasureFlatHist::kBins) / (bhi - blo);
|
||||||
|
for (std::size_t b = 0; b < count; ++b) {
|
||||||
|
const MeasureBucket& k = buckets[b];
|
||||||
|
if (k.count == 0) continue;
|
||||||
|
const double mid = 0.5 * (k.lo + k.hi);
|
||||||
|
std::size_t bin = mid <= blo ? 0u : static_cast<std::size_t>((mid - blo) * scale);
|
||||||
|
if (bin >= MeasureFlatHist::kBins) bin = MeasureFlatHist::kBins - 1;
|
||||||
|
s.flat.sum[bin] += k.sum;
|
||||||
|
s.flat.count[bin] += k.count;
|
||||||
|
++s.flat.buckets[bin];
|
||||||
|
++s.flat.folded;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void measure_accumulate(const MeasureChunk& chunk,
|
void measure_accumulate(const MeasureChunk& chunk,
|
||||||
@@ -219,7 +272,9 @@ bool measure_accumulate_bucket(const MeasureBucket& k,
|
|||||||
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
|
const bool fully_outside = above ? (k.lo > top) : (k.hi < bot);
|
||||||
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
|
// Nothing may be folded before this point: a refusal hands the bucket to `measure_accumulate()`,
|
||||||
// which adds the same samples again. Sums added above the refusal double-counted every opened
|
// which adds the same samples again. Sums added above the refusal double-counted every opened
|
||||||
// bucket, which read as `top` 0.614304 on a unit sine against the 2/pi it must be.
|
// bucket -- which read as `top` 0.614304 on a unit sine against the 2/pi a mean over a half then
|
||||||
|
// gave, and which is now the sums `Top`/`Bottom` fall back to on a window with no histogram, so
|
||||||
|
// a wrong one is a wrong answer waiting for that window.
|
||||||
if (change && !fully_outside) return false;
|
if (change && !fully_outside) return false;
|
||||||
acc.sum[e] += k.sum;
|
acc.sum[e] += k.sum;
|
||||||
acc.count[e] += k.count;
|
acc.count[e] += k.count;
|
||||||
@@ -231,6 +286,62 @@ bool measure_accumulate_bucket(const MeasureBucket& k,
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MeasureFlatLevels measure_flat_levels(const MeasureStats& b, double pct) {
|
||||||
|
MeasureFlatLevels out;
|
||||||
|
const MeasureFlatHist& h = b.flat;
|
||||||
|
const double span = b.hi - b.lo;
|
||||||
|
if (h.folded == 0 || !(h.hi > h.lo) || !(span > 0.0)) return out;
|
||||||
|
// The split, sized exactly as `measure_set_level()` sizes the level the crossing types read, so
|
||||||
|
// "the top" and "the level a period is counted about" are placed by one rule.
|
||||||
|
const double level = b.lo + std::clamp(pct, 0.0, 1.0) * span;
|
||||||
|
const double width = (h.hi - h.lo) / static_cast<double>(MeasureFlatHist::kBins);
|
||||||
|
const double band = kFlatBandShare * span;
|
||||||
|
for (int upper = 1; upper >= 0; --upper) {
|
||||||
|
const auto in_side = [&](std::size_t i) {
|
||||||
|
const double centre = h.lo + (static_cast<double>(i) + 0.5) * width;
|
||||||
|
return (centre > level) == (upper != 0);
|
||||||
|
};
|
||||||
|
// One scan for the densest bin of this side, one for the outermost of the equally dense
|
||||||
|
// ones, one to sum the band about it: 192 iterations of an array of 64, per flat level.
|
||||||
|
std::size_t densest = 0;
|
||||||
|
for (std::size_t i = 0; i < MeasureFlatHist::kBins; ++i) {
|
||||||
|
if (in_side(i) && h.buckets[i] > densest) densest = h.buckets[i];
|
||||||
|
}
|
||||||
|
// A side of the split with no bucket in it has no level: `top` on a window that never reaches
|
||||||
|
// above the split reads `--`, which is exactly what it read before.
|
||||||
|
if (densest == 0) continue;
|
||||||
|
const double floor_buckets = kFlatTieShare * static_cast<double>(densest);
|
||||||
|
std::size_t anchor = 0;
|
||||||
|
bool have_anchor = false;
|
||||||
|
for (std::size_t i = 0; i < MeasureFlatHist::kBins; ++i) {
|
||||||
|
if (!in_side(i) || static_cast<double>(h.buckets[i]) < floor_buckets) continue;
|
||||||
|
// The upper side wants the *last* qualifier and the lower one the first, so a bottom-up scan
|
||||||
|
// is "always overwrite" for one and "only ever write once" for the other.
|
||||||
|
if (upper != 0 || !have_anchor) anchor = i;
|
||||||
|
have_anchor = true;
|
||||||
|
}
|
||||||
|
if (!have_anchor) continue;
|
||||||
|
const double centre = h.lo + (static_cast<double>(anchor) + 0.5) * width;
|
||||||
|
double sum = 0.0;
|
||||||
|
std::size_t count = 0;
|
||||||
|
for (std::size_t i = 0; i < MeasureFlatHist::kBins; ++i) {
|
||||||
|
const double offset = h.lo + (static_cast<double>(i) + 0.5) * width - centre;
|
||||||
|
if (offset < -band || offset > band) continue;
|
||||||
|
sum += h.sum[i];
|
||||||
|
count += h.count[i];
|
||||||
|
}
|
||||||
|
if (count == 0) continue;
|
||||||
|
if (upper != 0) {
|
||||||
|
out.top = sum / static_cast<double>(count);
|
||||||
|
out.top_ok = true;
|
||||||
|
} else {
|
||||||
|
out.bottom = sum / static_cast<double>(count);
|
||||||
|
out.bottom_ok = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct) {
|
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case MeasureType::Top:
|
case MeasureType::Top:
|
||||||
@@ -323,11 +434,11 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
|
|||||||
out.valid = true;
|
out.valid = true;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
case MeasureType::PeakToPeak:
|
case MeasureType::PeakToPeak: {
|
||||||
case MeasureType::Amplitude: {
|
// Straight out of the window's own extremes, which every type already needs: this one *is*
|
||||||
// Both come straight out of the window's own extremes, which every type already needs:
|
// the extremes, ripple and overshoot included -- that is the difference between it and
|
||||||
// the two rulers are the same pair, and the numbers differ by a factor of two.
|
// `Amplitude`, which reads the two levels the signal dwells at instead.
|
||||||
out.primary = type == MeasureType::PeakToPeak ? b.hi - b.lo : 0.5 * (b.hi - b.lo);
|
out.primary = b.hi - b.lo;
|
||||||
// A ruler at each extreme: the *span* is the measurement, and two levels are how a
|
// A ruler at each extreme: the *span* is the measurement, and two levels are how a
|
||||||
// plot states a span without a band between them.
|
// plot states a span without a band between them.
|
||||||
out.marks.push_back({true, b.lo});
|
out.marks.push_back({true, b.lo});
|
||||||
@@ -335,13 +446,50 @@ MeasureResult compute_measure(const MeasureStats& b, const MeasureLevel* level,
|
|||||||
out.valid = true;
|
out.valid = true;
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
case MeasureType::Amplitude: {
|
||||||
|
// The delta between the two flat levels, so a ringing edge's overshoot is not part of it.
|
||||||
|
// The split is the mid level, fixed rather than an option -- the dialog offers no threshold
|
||||||
|
// for this type -- and the reading is insensitive to it anyway: the band a level is read from
|
||||||
|
// is a *value* band, so any split between the two plateaus picks the same two.
|
||||||
|
const MeasureFlatLevels flat = measure_flat_levels(b, 0.5);
|
||||||
|
if (flat.top_ok && flat.bottom_ok) {
|
||||||
|
out.primary = flat.top - flat.bottom;
|
||||||
|
out.marks.push_back({true, flat.top});
|
||||||
|
out.marks.push_back({true, flat.bottom});
|
||||||
|
out.valid = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// No density estimate to separate a plateau from its ringing: a window shorter than a
|
||||||
|
// bucket, or an HF-rejected walk, which cannot use the pyramid at all. The delta then
|
||||||
|
// degenerates to the window's own peak to peak -- the same number when there is nothing to
|
||||||
|
// exclude, and the only honest one when the ripple cannot be told from the level.
|
||||||
|
out.primary = b.hi - b.lo;
|
||||||
|
out.marks.push_back({true, b.lo});
|
||||||
|
out.marks.push_back({true, b.hi});
|
||||||
|
out.valid = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
case MeasureType::Top:
|
case MeasureType::Top:
|
||||||
case MeasureType::Bottom: {
|
case MeasureType::Bottom: {
|
||||||
// The mean of one side of the level, straight out of the second pass' running sums. A flat
|
// The flat level of one side of the split: the densest band there, whose samples' exact mean
|
||||||
// window has no range at all, so every sample lands on the "below" side -- which is why
|
// is the value. The band is a *value* band, so the reading does not follow the window's
|
||||||
// `top` on a signal that never moves reads `--` rather than that one value.
|
// alignment, and it excludes both a plateau's ripple and an edge's overshoot -- which is the
|
||||||
|
// whole point, because those are exactly what a mean over a side reports instead.
|
||||||
|
double pct = 0.0;
|
||||||
|
if (!measure_level_pct(type, options, pct)) return out;
|
||||||
|
const bool want_top = type == MeasureType::Top;
|
||||||
|
const MeasureFlatLevels flat = measure_flat_levels(b, pct);
|
||||||
|
if (want_top ? flat.top_ok : flat.bottom_ok) {
|
||||||
|
out.primary = want_top ? flat.top : flat.bottom;
|
||||||
|
out.marks.push_back({true, out.primary});
|
||||||
|
out.valid = true;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
// No histogram: the mean of one side of the level, straight out of the second pass' running
|
||||||
|
// sums. A window the pyramid cannot summarize is one bucket or two of a zoomed-in view, or
|
||||||
|
// an HF-rejected walk, and on those the split's own means are the honest reading.
|
||||||
if (level == nullptr) return out;
|
if (level == nullptr) return out;
|
||||||
const std::size_t s = type == MeasureType::Top ? 1 : 0;
|
const std::size_t s = want_top ? 1 : 0;
|
||||||
if (level->count[s] == 0) return out;
|
if (level->count[s] == 0) return out;
|
||||||
out.primary = level->sum[s] / static_cast<double>(level->count[s]);
|
out.primary = level->sum[s] / static_cast<double>(level->count[s]);
|
||||||
out.marks.push_back({true, out.primary});
|
out.marks.push_back({true, out.primary});
|
||||||
|
|||||||
+90
-16
@@ -17,15 +17,24 @@ enum class MeasureType {
|
|||||||
Min, ///< the smallest visible value
|
Min, ///< the smallest visible value
|
||||||
Max, ///< the largest visible value
|
Max, ///< the largest visible value
|
||||||
/// The largest swing in the window: `max - min`, the two extremes the eye would measure
|
/// The largest swing in the window: `max - min`, the two extremes the eye would measure
|
||||||
/// with a ruler across the waveform.
|
/// with a ruler across the waveform -- ripple and overshoot included, which is what makes it
|
||||||
|
/// the *extremes* and not a level.
|
||||||
PeakToPeak,
|
PeakToPeak,
|
||||||
/// Half the peak-to-peak swing, `(max - min) / 2` -- the amplitude of a symmetric waveform
|
/// The delta between the two **flat** levels, `top - bottom` (a bench scope's `Vamplitude`,
|
||||||
/// about its own mean, which is the figure a datasheet quotes and a signal generator is set
|
/// which is `Vtop - Vbase`). Deliberately *not* the peak-to-peak swing: a band-limited edge
|
||||||
/// to. Deliberately not `max(|value|)`: that is a *distance from zero*, which for a signal
|
/// overshoots and rings, so the extremes of a nominal 2 V square read about 2.36 V, while the
|
||||||
/// riding on an offset is a different and less useful number.
|
/// levels the signal actually dwells at are ±1 and their delta exactly 2. Where the window
|
||||||
|
/// has no density estimate to separate a plateau from its ripple (shorter than one bucket, or
|
||||||
|
/// an HF-rejected walk) it degenerates to the window's own peak to peak, which is the same
|
||||||
|
/// number on a waveform with nothing to exclude. Never `max(|value|)`: that is a *distance
|
||||||
|
/// from zero*, which for a signal riding on an offset is a different and less useful number.
|
||||||
Amplitude,
|
Amplitude,
|
||||||
Top, ///< the signal's high level (the mean of the samples above the threshold)
|
/// The signal's high level: the densest band of the window's upper side, whose samples' exact
|
||||||
Bottom, ///< the signal's low level (the mean of the samples below it)
|
/// mean is the value -- the *flat top*, so a plateau's ripple and an edge's overshoot neither
|
||||||
|
/// define it nor enter it.
|
||||||
|
Top,
|
||||||
|
/// The signal's low level, the same way from the window's lower side.
|
||||||
|
Bottom,
|
||||||
PulseDuration, ///< how long the signal spends on one side of the threshold
|
PulseDuration, ///< how long the signal spends on one side of the threshold
|
||||||
Period, ///< the time between two successive crossings of the threshold
|
Period, ///< the time between two successive crossings of the threshold
|
||||||
Frequency, ///< 1 / period
|
Frequency, ///< 1 / period
|
||||||
@@ -92,6 +101,35 @@ struct MeasureResult {
|
|||||||
bool valid = false;
|
bool valid = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// The density estimate behind `Top`/`Bottom`/`Amplitude`: one bin per band of the window's
|
||||||
|
/// value range, over the whole LOD buckets pass one walked.
|
||||||
|
///
|
||||||
|
/// A flat level is a *density* question, not an extreme one: on a band-limited square the
|
||||||
|
/// plateau holds most of the samples while the edge's ringing holds a few percent, so the
|
||||||
|
/// densest band is the level the signal dwells at and the ringing is what a mean over a side
|
||||||
|
/// reports instead. One bucket is one observation at its midpoint -- a bucket is 64 samples
|
||||||
|
/// held as one aggregate, so counting its samples would weight a flat region by nothing at all
|
||||||
|
/// and a ringing one by 64 -- and each bin also carries the exact sum and count of the samples
|
||||||
|
/// its buckets hold, which is what makes the reported level an exact mean rather than a bin
|
||||||
|
/// centre. The bucket list is the one pass one already fetches; no pass reads raw samples for
|
||||||
|
/// this, which is the whole reason it is built from buckets and not from a sample histogram
|
||||||
|
/// (that would be a second O(window) walk).
|
||||||
|
struct MeasureFlatHist {
|
||||||
|
/// Bins across the buckets' own range. The band the level is read from is picked at this
|
||||||
|
/// resolution and is a share of the *window's* peak-to-peak, so the bin count only sets how
|
||||||
|
/// finely the densest band can be located.
|
||||||
|
static constexpr std::size_t kBins = 64;
|
||||||
|
double lo = 0.0; ///< the bin range: the whole buckets' own smallest `lo`..
|
||||||
|
double hi = 0.0; ///< ..and largest `hi`. Empty when nothing was folded.
|
||||||
|
double sum[kBins] = {};
|
||||||
|
std::size_t count[kBins] = {};
|
||||||
|
std::size_t buckets[kBins] = {};
|
||||||
|
/// Whole buckets folded in: 0 means this window has no histogram at all (a window shorter
|
||||||
|
/// than a bucket, or an HF-rejected walk, which cannot use the pyramid), and the flat-level
|
||||||
|
/// types then fall back to their split-relative readings.
|
||||||
|
std::size_t folded = 0;
|
||||||
|
};
|
||||||
|
|
||||||
/// The window's own extremes, which every threshold-relative type scales from and which
|
/// The window's own extremes, which every threshold-relative type scales from and which
|
||||||
/// several types report directly. Public so a caller holding *several* measurements on one
|
/// several types report directly. Public so a caller holding *several* measurements on one
|
||||||
/// signal can pay for the scan once: it is O(visible samples) and a live view at 1 MS/s makes
|
/// signal can pay for the scan once: it is O(visible samples) and a live view at 1 MS/s makes
|
||||||
@@ -119,6 +157,10 @@ struct MeasureStats {
|
|||||||
/// `compute_measure()` reads these, so a caller that skips the resolve reports t = 0.
|
/// `compute_measure()` reads these, so a caller that skips the resolve reports t = 0.
|
||||||
double at_lo = 0.0;
|
double at_lo = 0.0;
|
||||||
double at_hi = 0.0;
|
double at_hi = 0.0;
|
||||||
|
/// The flat levels' density estimate, folded from the same whole buckets pass one walked.
|
||||||
|
/// The *values* are resolved by `measure_flat_levels()`, which needs the split and the
|
||||||
|
/// window's span and so cannot be done until the walk is over.
|
||||||
|
MeasureFlatHist flat;
|
||||||
bool valid = false;
|
bool valid = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -195,7 +237,14 @@ void measure_scan(const MeasureChunk& chunk, MeasureStats& stats, MeasureFilter&
|
|||||||
/// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's
|
/// Pass one from whole buckets: the same figures, exactly, for 1/64 of the reads. The extreme's
|
||||||
/// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw
|
/// position comes out as a *bucket* (`lo_search`/`hi_search` non-zero), which one raw
|
||||||
/// `measure_scan()` of that bucket then pins. Interleave with `measure_scan()` in window order.
|
/// `measure_scan()` of that bucket then pins. Interleave with `measure_scan()` in window order.
|
||||||
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& stats);
|
///
|
||||||
|
/// `flat_hist` folds the same list into `stats.flat` for the flat-level types, in a second tight
|
||||||
|
/// loop over the array (the bin range is the buckets' own, which the first loop is what
|
||||||
|
/// discovers). That loop is O(buckets) and reads no sample, so the `measure cost:` line's `raw`
|
||||||
|
/// is unchanged by it -- the cost is buckets, which is the budget the pane is on. It is the
|
||||||
|
/// caller's because only the caller knows whether any measurement reads a flat level.
|
||||||
|
void measure_scan_buckets(const MeasureBucket* buckets, std::size_t count, MeasureStats& stats,
|
||||||
|
bool flat_hist);
|
||||||
|
|
||||||
/// Everything the threshold-relative types need from a second walk, at one level: the sum and
|
/// Everything the threshold-relative types need from a second walk, at one level: the sum and
|
||||||
/// count of the samples on either side of it, and its crossings reduced to running figures -- the
|
/// count of the samples on either side of it, and its crossings reduced to running figures -- the
|
||||||
@@ -215,12 +264,17 @@ struct MeasureLevel {
|
|||||||
/// `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz. With the band, `period=0.200188`.
|
/// `period=0.025622 freq=39.029` for a true 0.2 s / 5 Hz. With the band, `period=0.200188`.
|
||||||
///
|
///
|
||||||
/// It moves the *edge decision* only. The `sum`/`count` split below stays at the bare `level`,
|
/// It moves the *edge decision* only. The `sum`/`count` split below stays at the bare `level`,
|
||||||
/// because that is what `Top`/`Bottom` are -- the mean of the samples above/below the threshold --
|
/// so a band can never change it -- the split is the split, and a sample's side of it is a
|
||||||
/// and a band there would change a documented exact reading (`top` on a unit sine is 2/pi).
|
/// fact rather than a threshold decision. `measure_set_level()` is what sizes it; zero reduces
|
||||||
/// `measure_set_level()` is what sizes it; zero reduces the walk exactly to a single level.
|
/// the walk exactly to a single level.
|
||||||
double hysteresis = 0.0;
|
double hysteresis = 0.0;
|
||||||
/// Index 0 is the side below the level (a sample exactly *at* it counts here, which is what
|
/// The samples either side of `level`, exactly: `Top`/`Bottom`'s reading on a window with no
|
||||||
/// `bottom` has always reported), index 1 the side above it.
|
/// bucket histogram to read a flat level from (shorter than one bucket, or an HF-rejected
|
||||||
|
/// walk). They are folded on every window -- pass two walks it for the crossing types anyway
|
||||||
|
/// -- but a window that has a histogram is read from that instead, because a mean over a side
|
||||||
|
/// reports the ripple and the overshoot rather than the level the signal dwells at.
|
||||||
|
/// Index 0 is the side below the level (a sample exactly *at* it counts here), index 1 the
|
||||||
|
/// side above it.
|
||||||
double sum[2] = {0.0, 0.0};
|
double sum[2] = {0.0, 0.0};
|
||||||
std::size_t count[2] = {0, 0};
|
std::size_t count[2] = {0, 0};
|
||||||
double first_enter[2] = {0.0, 0.0};
|
double first_enter[2] = {0.0, 0.0};
|
||||||
@@ -275,9 +329,29 @@ bool measure_accumulate_bucket(const MeasureBucket& bucket,
|
|||||||
/// The level a type reads, as a share of the window's own min..max, or false when it reads none
|
/// The level a type reads, as a share of the window's own min..max, or false when it reads none
|
||||||
/// (the pure statistics, answered from `MeasureStats` alone). `Top`/`Bottom` read the
|
/// (the pure statistics, answered from `MeasureStats` alone). `Top`/`Bottom` read the
|
||||||
/// measurement's own threshold; the crossing types read the mid level, deliberately -- see
|
/// measurement's own threshold; the crossing types read the mid level, deliberately -- see
|
||||||
/// `measure_uses_threshold()`.
|
/// `measure_uses_threshold()`. `Amplitude` reads no level either: its value is the delta between
|
||||||
|
/// the two flat levels, which the histogram answers, and the split's two means are only a
|
||||||
|
/// fallback it takes from the window's own extremes -- see `compute_measure()`.
|
||||||
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct);
|
bool measure_level_pct(MeasureType type, const MeasureOptions& options, double& pct);
|
||||||
|
|
||||||
|
/// The window's two flat levels, from the bucket histogram in `stats.flat`. `top` is the densest
|
||||||
|
/// band of the bins *above* `lo + pct * (hi - lo)`, `bottom` the same below it -- for a
|
||||||
|
/// crossing type's mid level that is the two plateaus of a pulse train, and for `Top`/`Bottom`'s
|
||||||
|
/// own threshold it is "which side is the level I want". Each side is false when it holds no
|
||||||
|
/// bucket, and both are false when the window has no histogram at all.
|
||||||
|
///
|
||||||
|
/// Two things this does not do. It does not read a sample: every figure comes from buckets pass
|
||||||
|
/// one already fetched. And it does not read the window raw, which is why the answer is
|
||||||
|
/// independent of how the window happens to be aligned -- the band is a *value* band, so the
|
||||||
|
/// level it reports does not move when the window does.
|
||||||
|
struct MeasureFlatLevels {
|
||||||
|
double top = 0.0;
|
||||||
|
bool top_ok = false;
|
||||||
|
double bottom = 0.0;
|
||||||
|
bool bottom_ok = false;
|
||||||
|
};
|
||||||
|
MeasureFlatLevels measure_flat_levels(const MeasureStats& stats, double pct);
|
||||||
|
|
||||||
/// The engine: one measurement's answer, O(1), from the two passes' results. `level` is the
|
/// The engine: one measurement's answer, O(1), from the two passes' results. `level` is the
|
||||||
/// accumulator for `measure_level_pct(type, options)` and may be null for the types that read
|
/// accumulator for `measure_level_pct(type, options)` and may be null for the types that read
|
||||||
/// none. The function reads nothing else, which is what keeps the answer independent of the plot.
|
/// none. The function reads nothing else, which is what keeps the answer independent of the plot.
|
||||||
@@ -298,8 +372,8 @@ bool measure_uses_threshold(MeasureType type);
|
|||||||
/// the waveform is the pulse).
|
/// the waveform is the pulse).
|
||||||
bool measure_uses_side(MeasureType type);
|
bool measure_uses_side(MeasureType type);
|
||||||
/// Whether a type reads the hysteresis band, so the dialog can show the field only then. The band moves
|
/// Whether a type reads the hysteresis band, so the dialog can show the field only then. The band moves
|
||||||
/// the *edge* decision and nothing else, so it is exactly the crossing types: `Top`/`Bottom` split their
|
/// the *edge* decision and nothing else, so it is exactly the crossing types: `Top`/`Bottom` read a flat
|
||||||
/// sums at the bare level and read the same answer at any band.
|
/// level out of the bucket histogram, which no band touches.
|
||||||
bool measure_uses_noise(MeasureType type);
|
bool measure_uses_noise(MeasureType type);
|
||||||
/// Whether a type draws its result on the plot when asked.
|
/// Whether a type draws its result on the plot when asked.
|
||||||
bool measure_can_show_on_plot(MeasureType type);
|
bool measure_can_show_on_plot(MeasureType type);
|
||||||
|
|||||||
@@ -632,6 +632,20 @@ class ScopeModel {
|
|||||||
/// `mutex()` and the producer holds that lock for all of `on_data`, so a plain read under the
|
/// `mutex()` and the producer holds that lock for all of `on_data`, so a plain read under the
|
||||||
/// lock is correct, and a reconnect with an identical CONFIG is a re-plan this sees and the
|
/// lock is correct, and a reconnect with an identical CONFIG is a re-plan this sees and the
|
||||||
/// stride/channel-count comparison does not.
|
/// stride/channel-count comparison does not.
|
||||||
|
/// A restarted client is a **new stream**, and the retained history belongs to the old one: its clock may
|
||||||
|
/// sit behind the stamps the previous stream wrote, and the producer's monotonic guard would then pin
|
||||||
|
/// every later packet flat at the old value -- the `ring=[x..x]` a display that never moves shows.
|
||||||
|
/// `reset()` is what the data-path invariants require of a rebuild (nothing of the old history behind),
|
||||||
|
/// and `plan_generation_` moving is what tells the producer to drop the state tied to the old store.
|
||||||
|
void reset_stream() {
|
||||||
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
|
// Null before the first CONFIG has planned anything: `Acquisition::start()` calls this, and at start-up
|
||||||
|
// there is no store yet. Dereferencing it there was a SIGSEGV with no output at all.
|
||||||
|
if (store_ != nullptr) store_->reset();
|
||||||
|
++plan_generation_;
|
||||||
|
++generation_;
|
||||||
|
}
|
||||||
|
|
||||||
unsigned plan_generation() const noexcept { return plan_generation_; }
|
unsigned plan_generation() const noexcept { return plan_generation_; }
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ Settings load_settings() {
|
|||||||
s.relative_time = parse_bool(value, s.relative_time);
|
s.relative_time = parse_bool(value, s.relative_time);
|
||||||
} else if (key == "theme") {
|
} else if (key == "theme") {
|
||||||
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
|
if (value == "auto" || value == "light" || value == "dark") s.theme = value;
|
||||||
|
} else if (key == "contextual_help") {
|
||||||
|
s.contextual_help = parse_bool(value, s.contextual_help);
|
||||||
|
} else if (key == "tour_seen") {
|
||||||
|
s.tour_seen = parse_bool(value, s.tour_seen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
@@ -80,6 +84,12 @@ bool save_settings(const Settings& settings) {
|
|||||||
out << "event_cache_max = " << settings.event_cache_max << "\n";
|
out << "event_cache_max = " << settings.event_cache_max << "\n";
|
||||||
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
|
out << "relative_time = " << (settings.relative_time ? "true" : "false") << "\n";
|
||||||
out << "theme = \"" << settings.theme << "\"\n";
|
out << "theme = \"" << settings.theme << "\"\n";
|
||||||
|
// The two help keys are the *app's* memory rather than the dialog's: the contextual-help rows --
|
||||||
|
// the Help menu's and the source chooser's own -- write the first, and the first-run tour writes the
|
||||||
|
// second. `read_rows()` in the preferences starts from `load_settings()`, so a dialog save carries
|
||||||
|
// them through untouched.
|
||||||
|
out << "contextual_help = " << (settings.contextual_help ? "true" : "false") << "\n";
|
||||||
|
out << "tour_seen = " << (settings.tour_seen ? "true" : "false") << "\n";
|
||||||
out.flush();
|
out.flush();
|
||||||
return out.good();
|
return out.good();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ struct Settings {
|
|||||||
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
|
/// "auto", "light" or "dark": what the interface should look like. Kept as the three names libadwaita
|
||||||
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
|
/// uses, so the file says what the setting is rather than what an enum happens to be numbered.
|
||||||
std::string theme = "auto";
|
std::string theme = "auto";
|
||||||
|
/// The contextual-help layer: a small "?" beside each surface it can explain. **Off by default** --
|
||||||
|
/// a first run shows the interface as a user expects it and the layer is asked for, from the Help
|
||||||
|
/// menu or from the chooser's own help menu, by whoever wants it; on is then a decision that
|
||||||
|
/// survives a restart.
|
||||||
|
bool contextual_help = false;
|
||||||
|
/// Whether the guided tour has been offered. A first run has no file carrying this key, so the tour
|
||||||
|
/// starts itself once; the flag is written when it *starts*, not when it finishes, because a user who
|
||||||
|
/// skips it has still seen it and a second launch with the same tour would be a nuisance.
|
||||||
|
bool tour_seen = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/// The defaults, as a value rather than as a comment: what the dialog's *Defaults* button writes and
|
/// The defaults, as a value rather than as a comment: what the dialog's *Defaults* button writes and
|
||||||
|
|||||||
+5
-1
@@ -22,9 +22,13 @@ constexpr Accel kAccels[] = {
|
|||||||
"Panes and editors"},
|
"Panes and editors"},
|
||||||
{GDK_KEY_e, GDK_CONTROL_MASK, AppAction::Export, "<Control>e",
|
{GDK_KEY_e, GDK_CONTROL_MASK, AppAction::Export, "<Control>e",
|
||||||
"Export the visible range", "Files and windows"},
|
"Export the visible range", "Files and windows"},
|
||||||
{GDK_KEY_F1, 0, AppAction::Help, "F1", "Open the help window", "Files and windows"},
|
{GDK_KEY_F1, 0, AppAction::Help, "F1", "Open the documentation", "Files and windows"},
|
||||||
{GDK_KEY_slash, GDK_CONTROL_MASK, AppAction::Shortcuts, "<Control>slash",
|
{GDK_KEY_slash, GDK_CONTROL_MASK, AppAction::Shortcuts, "<Control>slash",
|
||||||
"Open this shortcut overview", "Files and windows"},
|
"Open this shortcut overview", "Files and windows"},
|
||||||
|
{GDK_KEY_t, GDK_CONTROL_MASK, AppAction::GuidedTour, "<Control>t",
|
||||||
|
"Start the guided tour", "Files and windows"},
|
||||||
|
{GDK_KEY_h, GDK_CONTROL_MASK, AppAction::ContextualHelp, "<Control>h",
|
||||||
|
"Turn the contextual help on or off", "Files and windows"},
|
||||||
{GDK_KEY_q, GDK_CONTROL_MASK, AppAction::Quit, "<Control>q", "Quit udpscope",
|
{GDK_KEY_q, GDK_CONTROL_MASK, AppAction::Quit, "<Control>q", "Quit udpscope",
|
||||||
"Files and windows"},
|
"Files and windows"},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ enum class AppAction {
|
|||||||
Export,
|
Export,
|
||||||
Help,
|
Help,
|
||||||
Shortcuts,
|
Shortcuts,
|
||||||
|
GuidedTour,
|
||||||
|
ContextualHelp,
|
||||||
Quit,
|
Quit,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+389
-6
@@ -5,8 +5,13 @@
|
|||||||
|
|
||||||
#include "waveform.hpp"
|
#include "waveform.hpp"
|
||||||
|
|
||||||
|
#include "help_window.hpp"
|
||||||
#include "layout_store.hpp"
|
#include "layout_store.hpp"
|
||||||
#include "settings.hpp"
|
#include "settings.hpp"
|
||||||
|
// The app's shared chrome -- `ui_impl::menu_button()`/`menu_toggle()` and `install_css()` -- so the
|
||||||
|
// chooser's help menu is the *same* menu the window's Help view is, not a second one that merely
|
||||||
|
// looks like it. Heavy header, but a row shape copied here would be a row shape that drifts.
|
||||||
|
#include "ui_app.hpp"
|
||||||
#include "ui_prefs.hpp"
|
#include "ui_prefs.hpp"
|
||||||
|
|
||||||
#include <adwaita.h>
|
#include <adwaita.h>
|
||||||
@@ -16,6 +21,7 @@
|
|||||||
|
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
#include <cstdlib>
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
@@ -127,11 +133,49 @@ struct Chooser {
|
|||||||
/// The preferences, for the button below. Same reasoning as `form` above: a member of the chooser
|
/// The preferences, for the button below. Same reasoning as `form` above: a member of the chooser
|
||||||
/// rather than a heap struct, so nothing can outlive the stack frame that owns it.
|
/// rather than a heap struct, so nothing can outlive the stack frame that owns it.
|
||||||
ui_impl::PrefsForm prefs;
|
ui_impl::PrefsForm prefs;
|
||||||
|
/// The help menu: the `.ui`'s own `GtkMenuButton` and the three rows built onto it. The rows are
|
||||||
|
/// code for the same reason every other row here is -- each carries a handler, and the toggle
|
||||||
|
/// carries the value the settings file holds -- while the button that opens them is the file's, so
|
||||||
|
/// a `.ui` that lost it is caught by the null check below rather than by an empty popover.
|
||||||
|
GtkWidget* help_button = nullptr;
|
||||||
|
GtkWidget* help_docs = nullptr;
|
||||||
|
GtkWidget* help_tour = nullptr;
|
||||||
|
GtkWidget* help_toggle = nullptr;
|
||||||
|
GtkWidget* help_popover = nullptr;
|
||||||
|
/// The contextual help's "(i)" affordances, one per surface *this* window explains. They are not
|
||||||
|
/// the window's table -- `ui_context_help.cpp` names `App` fields, and there is no `App` here yet
|
||||||
|
/// -- so this window holds its own two rows and joins them with `context_help_button()`, which
|
||||||
|
/// draws the *one* "(i)" button both windows use.
|
||||||
|
GtkWidget* sources_head = nullptr;
|
||||||
|
GtkWidget* waves_actions = nullptr;
|
||||||
|
/// The guided tour of *this* window. It walks the chooser's own widgets and, when the walk
|
||||||
|
/// finishes, hands it on to the application through `Config::tour_pending`. The machinery is the
|
||||||
|
/// shared `ui_impl::TourView`; only the stops are this window's, because they name its widgets.
|
||||||
|
ui_impl::TourView tour;
|
||||||
|
/// The timer that auto-starts this tour on a first run, kept so a chooser that closes first can
|
||||||
|
/// cancel it (`&c` is a stack frame, and a timer that outlives it is the defect this project has
|
||||||
|
/// already paid for once).
|
||||||
|
guint tour_source = 0;
|
||||||
|
/// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`'s own timer, cancelled the same way.
|
||||||
|
guint tour_probe_source = 0;
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
void chooser_finish(Chooser& c, bool chosen) {
|
void chooser_finish(Chooser& c, bool chosen) {
|
||||||
c.chosen = chosen;
|
c.chosen = chosen;
|
||||||
|
// The two timers point into this stack frame (`&c`), so they go before the frame does. Both are
|
||||||
|
// cleared first: `g_source_remove()` on an id whose source already returned is a GLib critical.
|
||||||
|
if (c.tour_source != 0) {
|
||||||
|
g_source_remove(c.tour_source);
|
||||||
|
c.tour_source = 0;
|
||||||
|
}
|
||||||
|
if (c.tour_probe_source != 0) {
|
||||||
|
g_source_remove(c.tour_probe_source);
|
||||||
|
c.tour_probe_source = 0;
|
||||||
|
}
|
||||||
|
// The tour's own widgets go with the window; ending the walk first means the spotlight and the
|
||||||
|
// card are hidden rather than finalized by the destroy while still visible.
|
||||||
|
ui_impl::tour_end(c.tour);
|
||||||
// Destroy the form first: any path that reaches here while the form is up -- a row click, the
|
// Destroy the form first: any path that reaches here while the form is up -- a row click, the
|
||||||
// Escape key -- would otherwise leave a window whose four handlers are bound to a dead stack
|
// Escape key -- would otherwise leave a window whose four handlers are bound to a dead stack
|
||||||
// frame. `gtk_window_destroy()` rather than `gtk_window_close()`: the pointer is already
|
// frame. `gtk_window_destroy()` rather than `gtk_window_close()`: the pointer is already
|
||||||
@@ -162,6 +206,10 @@ void chooser_populate_waves(Chooser& c);
|
|||||||
void report_pick(const Chooser& c);
|
void report_pick(const Chooser& c);
|
||||||
/// Defined below; the row buttons reach it, and the row builder sits above it.
|
/// Defined below; the row buttons reach it, and the row builder sits above it.
|
||||||
void open_form(Chooser& c, int edit_index);
|
void open_form(Chooser& c, int edit_index);
|
||||||
|
/// The chooser's own help surfaces and its own tour, defined below: the help menu's rows and the
|
||||||
|
/// first-run timer call them, and both need the window's widgets resolved first.
|
||||||
|
void chooser_help_sync(Chooser& c);
|
||||||
|
void chooser_tour_begin(Chooser& c);
|
||||||
|
|
||||||
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
void on_row_activated(GtkListBox*, GtkListBoxRow* row, gpointer user) {
|
||||||
Chooser& c = *static_cast<Chooser*>(user);
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
@@ -596,14 +644,277 @@ void open_chooser_preferences(Chooser& c) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The chooser's help menu: the same three affordances the window's Help view carries, because a
|
||||||
|
/// user who has not started yet is exactly the one who does not know how to start. The rows are
|
||||||
|
/// `ui_impl::menu_button()`/`menu_toggle()` -- the app's own menu-row shape, so the two menus read
|
||||||
|
/// as one program -- and the popover carries the same `.udpscope-menu` class, which is what trims
|
||||||
|
/// the rows; `install_css()` therefore runs before this, in `choose_source()`.
|
||||||
|
///
|
||||||
|
/// No row shows an accelerator. The window's Help rows show "F1"/"Ctrl+T" because the window handles
|
||||||
|
/// those keys; this dialog binds none of them, and a shortcut printed beside an action that does not
|
||||||
|
/// answer to it is the "documented and does nothing" failure the shortcut tables are checked for.
|
||||||
|
void build_chooser_help(Chooser& c) {
|
||||||
|
c.help_popover = gtk_popover_new();
|
||||||
|
gtk_widget_add_css_class(c.help_popover, "udpscope-menu");
|
||||||
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
|
gtk_widget_set_margin_top(box, 6);
|
||||||
|
gtk_widget_set_margin_bottom(box, 6);
|
||||||
|
gtk_widget_set_margin_start(box, 6);
|
||||||
|
gtk_widget_set_margin_end(box, 6);
|
||||||
|
|
||||||
|
c.help_docs = ui_impl::menu_button("Documentation", nullptr);
|
||||||
|
gtk_widget_set_tooltip_text(c.help_docs,
|
||||||
|
"the udpscope manual, opened in the desktop help viewer");
|
||||||
|
g_signal_connect(c.help_docs, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
|
Chooser& ch = *static_cast<Chooser*>(user);
|
||||||
|
gtk_popover_popdown(GTK_POPOVER(ch.help_popover));
|
||||||
|
// No window of the app exists yet: the manual is a separate process reached
|
||||||
|
// through the `help:` URI, so this needs no more of the app than the chooser
|
||||||
|
// itself. The parent may be null, and this dialog is the right attribution.
|
||||||
|
show_help(GTK_WINDOW(ch.window));
|
||||||
|
}),
|
||||||
|
&c);
|
||||||
|
gtk_box_append(GTK_BOX(box), c.help_docs);
|
||||||
|
|
||||||
|
c.help_tour = ui_impl::menu_button("Guided tour", nullptr);
|
||||||
|
gtk_widget_set_tooltip_text(c.help_tour,
|
||||||
|
"a walk over this dialog's own widgets, then over the plot, the side "
|
||||||
|
"panel, the trigger, the signals and the status line once a source "
|
||||||
|
"has been opened");
|
||||||
|
g_signal_connect(c.help_tour, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
|
Chooser& ch = *static_cast<Chooser*>(user);
|
||||||
|
gtk_popover_popdown(GTK_POPOVER(ch.help_popover));
|
||||||
|
// The tour *starts here*, on this window: the chooser is what a first run meets
|
||||||
|
// first and what it has no way of knowing about. When this walk finishes it sets
|
||||||
|
// `Config::tour_pending`, so the application continues it once a source has been
|
||||||
|
// picked -- see `chooser_tour_begin()` and `chooser_tour_done()`.
|
||||||
|
chooser_tour_begin(ch);
|
||||||
|
std::fprintf(stderr, "[sources] chooser help: tour started in the chooser\n");
|
||||||
|
std::fflush(stderr);
|
||||||
|
}),
|
||||||
|
&c);
|
||||||
|
gtk_box_append(GTK_BOX(box), c.help_tour);
|
||||||
|
|
||||||
|
// The check *is* the setting, exactly as in the window's menu: the row is stated before the handler
|
||||||
|
// is connected, so a row that opens checked does not write the file while the dialog is still being
|
||||||
|
// built. The chooser re-syncs its own "(i)" layer here, the way the window re-syncs the window's.
|
||||||
|
c.help_toggle = ui_impl::menu_toggle("Enable contextual help", nullptr,
|
||||||
|
load_settings().contextual_help);
|
||||||
|
gtk_widget_set_tooltip_text(c.help_toggle,
|
||||||
|
"a small \u201c(i)\u201d beside each surface of this dialog and of the "
|
||||||
|
"main window, saying what that surface is for");
|
||||||
|
g_signal_connect(c.help_toggle, "toggled", G_CALLBACK(+[](GtkToggleButton* button, gpointer user) {
|
||||||
|
Chooser& ch = *static_cast<Chooser*>(user);
|
||||||
|
Settings s = load_settings();
|
||||||
|
s.contextual_help = gtk_toggle_button_get_active(button) != FALSE;
|
||||||
|
// Written in full, the way the window's toggle writes it: the keys this row does
|
||||||
|
// not own are carried through from the file rather than dropped.
|
||||||
|
if (!save_settings(s)) {
|
||||||
|
std::fprintf(stderr, "[sources] chooser help: could not write %s\n",
|
||||||
|
settings_path().c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
chooser_help_sync(ch);
|
||||||
|
}),
|
||||||
|
&c);
|
||||||
|
gtk_box_append(GTK_BOX(box), c.help_toggle);
|
||||||
|
|
||||||
|
gtk_popover_set_child(GTK_POPOVER(c.help_popover), box);
|
||||||
|
gtk_menu_button_set_popover(GTK_MENU_BUTTON(c.help_button), c.help_popover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The chooser's contextual help: the same "(i)" the window draws, on the two surfaces *this*
|
||||||
|
/// window has a row for -- the list of saved sources and the waveforms page's action row. It is
|
||||||
|
/// called once after the window is built and again by the Help menu's toggle, which is the one place
|
||||||
|
/// the setting can change while the dialog is up.
|
||||||
|
///
|
||||||
|
/// The form's rows are deliberately not a host: an `AdwEntryRow` is not a box and its editing widget
|
||||||
|
/// is internal, so a hint could not join it as a sibling -- it would have to be appended to the whole
|
||||||
|
/// `AdwPreferencesGroup` as a row of its own, which is not a hint *beside* the field but a row under
|
||||||
|
/// it. The connection fields are therefore explained by the group's own `description`, which is
|
||||||
|
/// where libadwaita puts a hint that must survive being typed over.
|
||||||
|
void chooser_help_sync(Chooser& c) {
|
||||||
|
const bool on = load_settings().contextual_help;
|
||||||
|
ui_impl::context_help_button(
|
||||||
|
c.sources_head, "chooser_sources", "The sources you have opened",
|
||||||
|
"One row per saved configuration. Clicking one opens it: it becomes the active configuration "
|
||||||
|
"and the stream starts. The pencil edits a row's name, note, buffer size and clock; the cross "
|
||||||
|
"forgets it and deletes its saved session.",
|
||||||
|
"", on);
|
||||||
|
ui_impl::context_help_button(
|
||||||
|
c.waves_actions, "chooser_waveform", "Opening a recording",
|
||||||
|
"A `.npy` an earlier run exported is opened for viewing and analysis instead of a live source: "
|
||||||
|
"there is no acquisition behind it, so the trigger and the rate counters are not there -- only "
|
||||||
|
"the samples, which can be measured, searched and re-exported.",
|
||||||
|
"", on);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One stop of the *chooser's* tour. Its targets are this window's widgets, which exist before `App`
|
||||||
|
/// does -- which is the whole reason the stops cannot live in the window's table.
|
||||||
|
struct ChooserStop {
|
||||||
|
GtkWidget* Chooser::* target;
|
||||||
|
/// The page of the chooser's own stack the stop stands on. The tabs stop is the one about the two
|
||||||
|
/// kinds of input, so it shows the *waveforms* page while it is up; every other stop stands on the
|
||||||
|
/// sources page, and naming it is what brings the page back when the walk steps off the tabs stop.
|
||||||
|
const char* page;
|
||||||
|
const char* title;
|
||||||
|
const char* body;
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr ChooserStop kChooserStops[] = {
|
||||||
|
{&Chooser::list, "sources", "Choosing a source",
|
||||||
|
"Every source you have saved, newest first. Clicking one opens it -- that is the choice this "
|
||||||
|
"dialog exists to make -- and the arrows on a row edit or forget its configuration."},
|
||||||
|
{&Chooser::switcher, "waveforms", "Two kinds of input",
|
||||||
|
"A *source* is a stream to receive; a *waveform* is a recording an earlier run exported. They "
|
||||||
|
"are the two answers to \u201cwhat should be on the screen\u201d, and these are the tabs "
|
||||||
|
"between them."},
|
||||||
|
{&Chooser::new_host, "sources", "A source that is not listed",
|
||||||
|
"Opens the form for an address that has no configuration yet: host, port, the settings a source "
|
||||||
|
"can only be given before it is opened, and the name its session will be saved under."},
|
||||||
|
{&Chooser::help_button, "sources", "The manual, and this tour",
|
||||||
|
"Documentation opens the manual in the desktop help viewer. This tour walks this window and "
|
||||||
|
"then the application's own window. Contextual help is the switch below it: on, every surface "
|
||||||
|
"carries a small \u201c(i)\u201d saying what it is for."},
|
||||||
|
{&Chooser::prefs_button, "sources", "Preferences",
|
||||||
|
"The event cache, relative time and the interface theme. This is the last moment relative time "
|
||||||
|
"can still change this run -- the buffers are planned as soon as a source is picked -- which is "
|
||||||
|
"why the button is here and not only in the window's menu."},
|
||||||
|
};
|
||||||
|
constexpr int kChooserStopCount = static_cast<int>(sizeof(kChooserStops) / sizeof(kChooserStops[0]));
|
||||||
|
|
||||||
|
/// The walk is over: the chooser hands the tour on to the application. This is the second half of the
|
||||||
|
/// two-window tour -- `tour_pending` is the value `main()` owns and hands to both `choose_source()`
|
||||||
|
/// and `run_ui()`, and the window starts its own tour from it once a source has been picked.
|
||||||
|
void chooser_tour_done(void* user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
|
c.cfg->tour_pending = true;
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[sources] chooser tour: finished %d stops -- the window continues it "
|
||||||
|
"(tour_pending=1)\n",
|
||||||
|
kChooserStopCount);
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "The tour has run" is the same `settings.tour_seen` the window's auto-start reads, written when
|
||||||
|
/// the walk *starts*: a user who skips it has still seen it.
|
||||||
|
void chooser_tour_mark_seen() {
|
||||||
|
Settings s = load_settings();
|
||||||
|
if (s.tour_seen) return;
|
||||||
|
s.tour_seen = true;
|
||||||
|
if (!save_settings(s)) {
|
||||||
|
std::fprintf(stderr, "[sources] tour: could not write %s: it will be offered again\n",
|
||||||
|
settings_path().c_str());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void chooser_tour_begin(Chooser& c) {
|
||||||
|
if (c.tour.card == nullptr) return;
|
||||||
|
chooser_tour_mark_seen();
|
||||||
|
// The chooser's own stack, so a stop can name the page it stands on (the window does the same with
|
||||||
|
// its sidebar in `start_tour()`).
|
||||||
|
c.tour.page_stack = c.page_stack;
|
||||||
|
// The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen.
|
||||||
|
std::vector<ui_impl::TourStop> stops;
|
||||||
|
stops.reserve(kChooserStopCount);
|
||||||
|
for (int i = 0; i < kChooserStopCount; ++i) {
|
||||||
|
GtkWidget* target = c.*(kChooserStops[i].target);
|
||||||
|
stops.push_back({target, kChooserStops[i].page, kChooserStops[i].title, kChooserStops[i].body});
|
||||||
|
}
|
||||||
|
ui_impl::tour_begin(c.tour, std::move(stops), chooser_tour_done, &c);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tour is drawn over the *whole* window, header bar included: two of its stops name the header's
|
||||||
|
/// own buttons, and an overlay placed inside the content would give them negative coordinates and
|
||||||
|
/// clamp its hole to the top edge. `AdwWindow`'s content takes the overlay and the toolbar view
|
||||||
|
/// becomes its child -- the builder still owns the tree, so there is no reference bookkeeping.
|
||||||
|
void build_chooser_tour(Chooser& c) {
|
||||||
|
GtkWidget* content = adw_window_get_content(ADW_WINDOW(c.window));
|
||||||
|
if (content == nullptr) return;
|
||||||
|
// The overlay has to *become* the window's content before the toolbar view can be adopted by it:
|
||||||
|
// `gtk_overlay_set_child()` refuses a child that still has a parent, so the two calls are in this
|
||||||
|
// order -- handing the window the overlay first is what unparents the toolbar view. The view is
|
||||||
|
// held across the swap rather than left to whatever the builder's object list keeps: it has no id
|
||||||
|
// for the builder to name it by, and the window's content was its only owner.
|
||||||
|
GtkWidget* overlay = gtk_overlay_new();
|
||||||
|
g_object_ref(content);
|
||||||
|
adw_window_set_content(ADW_WINDOW(c.window), overlay);
|
||||||
|
gtk_overlay_set_child(GTK_OVERLAY(overlay), content);
|
||||||
|
g_object_unref(content);
|
||||||
|
ui_impl::tour_build(c.tour, overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A first run is the run whose settings file does not say the tour has been offered -- the same key
|
||||||
|
/// the window's own auto-start reads. Delayed 1.5 s for the reason the window's is: the window has to
|
||||||
|
/// be up and laid out before anything points at it.
|
||||||
|
gboolean chooser_tour_autostart(gpointer user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
|
c.tour_source = 0;
|
||||||
|
std::fprintf(stderr, "[sources] tour: first run -- auto_started=1 in the chooser\n");
|
||||||
|
std::fflush(stderr);
|
||||||
|
chooser_tour_begin(c);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the probe has done its one Back step. One chooser runs per process, so a file-static is
|
||||||
|
/// enough and simpler than a field on `Chooser` that exists only for the probe.
|
||||||
|
bool g_chooser_tour_backed = false;
|
||||||
|
|
||||||
|
/// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`: the chooser's tour, read and walked a stop per tick -- the
|
||||||
|
/// cadence the window's own tour probe walks at, and for its reason: a page a stop selects is laid
|
||||||
|
/// out on the frame *after* the switch, so a reading taken in the switch's own tick would be of the
|
||||||
|
/// page the walk has just left. It starts the walk if one is not already running, so it does not
|
||||||
|
/// depend on the throwaway config being a first run; it reports the page the stack shows at every
|
||||||
|
/// stop; it steps **Back** off the stop that stands on the waveforms page and reads the page again
|
||||||
|
/// -- the other direction; then it finishes the walk. `page=waveforms` on that stop and `page=sources`
|
||||||
|
/// after the Back step is the pair the surface switch is read by.
|
||||||
|
gboolean chooser_tour_probe(gpointer user) {
|
||||||
|
Chooser& c = *static_cast<Chooser*>(user);
|
||||||
|
c.tour_probe_source = 0;
|
||||||
|
const auto page = [&c]() {
|
||||||
|
const char* name = adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack));
|
||||||
|
return name != nullptr ? name : "?";
|
||||||
|
};
|
||||||
|
if (!c.tour.running) chooser_tour_begin(c);
|
||||||
|
if (!c.tour.running) {
|
||||||
|
std::fprintf(stderr, "[sources] chooser tour: nothing to walk (page=%s)\n", page());
|
||||||
|
std::fflush(stderr);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
const std::size_t idx = static_cast<std::size_t>(c.tour.index);
|
||||||
|
const int n = static_cast<int>(c.tour.plan.size());
|
||||||
|
const ui_impl::TourStop& stop = c.tour.plan[idx];
|
||||||
|
std::fprintf(stderr, "[sources] chooser tour: step=%d/%d page=%s stop_page=%s target_mapped=%d\n",
|
||||||
|
c.tour.index + 1, n, page(), stop.page != nullptr ? stop.page : "-",
|
||||||
|
stop.target != nullptr && gtk_widget_get_mapped(stop.target) ? 1 : 0);
|
||||||
|
if (stop.page != nullptr && std::strcmp(stop.page, "waveforms") == 0 && !g_chooser_tour_backed) {
|
||||||
|
// Off it, Back: the previous stop stands on the sources page, and the page has to come back with
|
||||||
|
// it -- the other direction a surface switch has to work in.
|
||||||
|
g_chooser_tour_backed = true;
|
||||||
|
ui_impl::tour_advance(c.tour, -1);
|
||||||
|
std::fprintf(stderr, "[sources] chooser tour: back off the waveforms stop: step=%d/%d page=%s\n",
|
||||||
|
c.tour.index + 1, n, page());
|
||||||
|
} else {
|
||||||
|
ui_impl::tour_advance(c.tour, 1);
|
||||||
|
}
|
||||||
|
if (c.tour.running) {
|
||||||
|
c.tour_probe_source = g_timeout_add(250, chooser_tour_probe, &c);
|
||||||
|
} else {
|
||||||
|
std::fprintf(stderr, "[sources] chooser tour: after finish visible=0 page=%s tour_pending=%d\n",
|
||||||
|
page(), c.cfg->tour_pending ? 1 : 0);
|
||||||
|
}
|
||||||
|
std::fflush(stderr);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
|
/// `UDPSCOPE_PROBE_PICK_MS`: activate the first row, the way a click does.
|
||||||
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
|
/// `UDPSCOPE_PROBE_NEWHOST=host:port` drives the form instead of a row. The crash surface of a
|
||||||
/// dialog built, filled and destroyed is as much the point as the values, the same way
|
/// dialog built, filled and destroyed is as much the point as the values, the same way
|
||||||
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
|
/// `UDPSCOPE_PROBE_FIELDS_MS` is in `ui.cpp`.
|
||||||
/// The chooser's frame, read back from the `.ui` it was built from: the two pages by name and title, the
|
/// The chooser's frame, read back from the `.ui` it was built from: the two pages by name and title, the
|
||||||
/// switcher wired to the stack, and the four buttons the code resolved. `[help] ui=` does the same against
|
/// switcher wired to the stack, the four buttons the code resolved, and the help menu's three rows.
|
||||||
/// its tables -- the frame is what the file holds, so the frame is what is read, while the rows a user sees
|
/// `[help] ui=` does the same against its tables -- the frame is what the file holds, so the frame is
|
||||||
/// are read by the probes that drive them.
|
/// what is read, while the rows a user sees are read by the probes that drive them.
|
||||||
void report_chooser_ui(const Chooser& c) {
|
void report_chooser_ui(const Chooser& c) {
|
||||||
std::fprintf(stderr, "[sources] chooser ui:");
|
std::fprintf(stderr, "[sources] chooser ui:");
|
||||||
for (GtkWidget* w = gtk_widget_get_first_child(c.page_stack); w != nullptr;
|
for (GtkWidget* w = gtk_widget_get_first_child(c.page_stack); w != nullptr;
|
||||||
@@ -616,15 +927,28 @@ void report_chooser_ui(const Chooser& c) {
|
|||||||
adw_view_stack_page_get_title(page),
|
adw_view_stack_page_get_title(page),
|
||||||
adw_view_stack_page_get_icon_name(page) != nullptr ? "yes" : "NO");
|
adw_view_stack_page_get_icon_name(page) != nullptr ? "yes" : "NO");
|
||||||
}
|
}
|
||||||
|
std::string hint_ids;
|
||||||
|
const int hints = ui_impl::context_help_count_in(c.window, &hint_ids);
|
||||||
|
int hint_w = 0;
|
||||||
|
int hint_h = 0;
|
||||||
|
ui_impl::context_help_size_in(c.window, &hint_w, &hint_h);
|
||||||
std::fprintf(stderr,
|
std::fprintf(stderr,
|
||||||
" visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%d]\n",
|
" visible='%s' switcher_wired=%d buttons=[prefs=%d new_host=%d open=%d cancel=%d] "
|
||||||
|
"help=[button=%d docs=%d tour=%d toggle=%d on=%d] hints=[%d {%s}] hint=%dx%d\n",
|
||||||
adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)),
|
adw_view_stack_get_visible_child_name(ADW_VIEW_STACK(c.page_stack)),
|
||||||
adw_view_switcher_bar_get_stack(ADW_VIEW_SWITCHER_BAR(c.switcher)) ==
|
adw_view_switcher_bar_get_stack(ADW_VIEW_SWITCHER_BAR(c.switcher)) ==
|
||||||
ADW_VIEW_STACK(c.page_stack)
|
ADW_VIEW_STACK(c.page_stack)
|
||||||
? 1
|
? 1
|
||||||
: 0,
|
: 0,
|
||||||
c.prefs_button != nullptr ? 1 : 0, c.new_host != nullptr ? 1 : 0,
|
c.prefs_button != nullptr ? 1 : 0, c.new_host != nullptr ? 1 : 0,
|
||||||
c.waves_open != nullptr ? 1 : 0, c.cancel_button != nullptr ? 1 : 0);
|
c.waves_open != nullptr ? 1 : 0, c.cancel_button != nullptr ? 1 : 0,
|
||||||
|
c.help_button != nullptr ? 1 : 0, c.help_docs != nullptr ? 1 : 0,
|
||||||
|
c.help_tour != nullptr ? 1 : 0, c.help_toggle != nullptr ? 1 : 0,
|
||||||
|
c.help_toggle != nullptr &&
|
||||||
|
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(c.help_toggle))
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
hints, hint_ids.c_str(), hint_w, hint_h);
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -638,6 +962,35 @@ gboolean pick_probe(gpointer user) {
|
|||||||
// What the `.ui` contributed, before anything is driven: the pages, the switcher and the buttons.
|
// What the `.ui` contributed, before anything is driven: the pages, the switcher and the buttons.
|
||||||
report_chooser_ui(c);
|
report_chooser_ui(c);
|
||||||
|
|
||||||
|
// `UDPSCOPE_PROBE_CHOOSER_TOUR=1` and `UDPSCOPE_PROBE_CHOOSER_HELP=1` drive the help menu's two
|
||||||
|
// stateful rows the way a click does, and then fall through to the pick below -- the point of the
|
||||||
|
// tour entry is that it crosses *into the run this chooser starts*, so a probe that stopped here
|
||||||
|
// would assert nothing. The tour is read back out of the `Config` the run will be handed, which is
|
||||||
|
// the carrier; the toggle is read back out of the settings file it writes, since the app is built
|
||||||
|
// from that file and not from the dialog.
|
||||||
|
// `UDPSCOPE_PROBE_CHOOSER_TOUR=1` drives the help menu's Guided tour row the way a click does --
|
||||||
|
// which now starts a tour *in this dialog*, not merely a request for the window's -- then walks it
|
||||||
|
// to its last stop and past it, so the handoff is exercised, and then falls through to the pick
|
||||||
|
// below. The point of the tour entry is that it crosses into the run this chooser starts, so a
|
||||||
|
// probe that stopped here would assert nothing; the carrier is `Config::tour_pending`.
|
||||||
|
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_TOUR") != nullptr && c.help_tour != nullptr) {
|
||||||
|
g_signal_emit_by_name(c.help_tour, "clicked");
|
||||||
|
std::fprintf(stderr, "[sources] chooser tour: visible=%d steps=%d step=%d\n",
|
||||||
|
c.tour.running ? 1 : 0, static_cast<int>(c.tour.plan.size()), c.tour.index);
|
||||||
|
while (c.tour.running) ui_impl::tour_advance(c.tour, 1);
|
||||||
|
std::fprintf(stderr, "[sources] chooser tour: after finish visible=%d tour_pending=%d\n",
|
||||||
|
c.tour.running ? 1 : 0, c.cfg->tour_pending ? 1 : 0);
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
if (g_getenv("UDPSCOPE_PROBE_CHOOSER_HELP") != nullptr && c.help_toggle != nullptr) {
|
||||||
|
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(c.help_toggle), TRUE);
|
||||||
|
const Settings back = load_settings();
|
||||||
|
std::fprintf(stderr, "[sources] chooser help: toggle=%d setting=%d file=%s\n",
|
||||||
|
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(c.help_toggle)) ? 1 : 0,
|
||||||
|
back.contextual_help ? 1 : 0, settings_path().c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
}
|
||||||
|
|
||||||
// `UDPSCOPE_PROBE_CHOOSER_PREFS=1`: the preferences opened from *here* rather than from the app's
|
// `UDPSCOPE_PROBE_CHOOSER_PREFS=1`: the preferences opened from *here* rather than from the app's
|
||||||
// menu -- the entry point that exists because this dialog runs before `App`. It is driven through
|
// menu -- the entry point that exists because this dialog runs before `App`. It is driven through
|
||||||
// the same fill-and-apply the app's probe uses, and reports what the run about to start will get,
|
// the same fill-and-apply the app's probe uses, and reports what the run about to start will get,
|
||||||
@@ -906,15 +1259,32 @@ bool choose_source(Config& cfg) {
|
|||||||
c.new_host = GTK_WIDGET(gtk_builder_get_object(c.ui, "new_host_button"));
|
c.new_host = GTK_WIDGET(gtk_builder_get_object(c.ui, "new_host_button"));
|
||||||
c.waves_open = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_open"));
|
c.waves_open = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_open"));
|
||||||
c.cancel_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "cancel_button"));
|
c.cancel_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "cancel_button"));
|
||||||
|
c.help_button = GTK_WIDGET(gtk_builder_get_object(c.ui, "help_menu_button"));
|
||||||
|
c.sources_head = GTK_WIDGET(gtk_builder_get_object(c.ui, "sources_head"));
|
||||||
|
c.waves_actions = GTK_WIDGET(gtk_builder_get_object(c.ui, "waves_actions"));
|
||||||
if (c.window == nullptr || c.page_stack == nullptr || c.switcher == nullptr || c.list == nullptr ||
|
if (c.window == nullptr || c.page_stack == nullptr || c.switcher == nullptr || c.list == nullptr ||
|
||||||
c.wave_list == nullptr || c.prefs_button == nullptr || c.new_host == nullptr ||
|
c.wave_list == nullptr || c.prefs_button == nullptr || c.new_host == nullptr ||
|
||||||
c.waves_open == nullptr || c.cancel_button == nullptr) {
|
c.waves_open == nullptr || c.cancel_button == nullptr || c.help_button == nullptr ||
|
||||||
|
c.sources_head == nullptr || c.waves_actions == nullptr) {
|
||||||
std::fprintf(stderr, "[sources] chooser ui: source-chooser.ui is missing an id this code reads\n");
|
std::fprintf(stderr, "[sources] chooser ui: source-chooser.ui is missing an id this code reads\n");
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
adw_view_switcher_bar_set_stack(ADW_VIEW_SWITCHER_BAR(c.switcher), ADW_VIEW_STACK(c.page_stack));
|
adw_view_switcher_bar_set_stack(ADW_VIEW_SWITCHER_BAR(c.switcher), ADW_VIEW_STACK(c.page_stack));
|
||||||
|
|
||||||
|
// The help menu's rows are the window's own `menu_button()`/`menu_toggle()`, so its popover wants
|
||||||
|
// the same stylesheet the overflow menu is drawn with (`.udpscope-menu`, which trims a row to a
|
||||||
|
// menu row rather than Adwaita's 34 px button). The app installs it in `on_activate()`, which is
|
||||||
|
// *after* this dialog, so it has to be installed here first; `install_css()` is idempotent, so
|
||||||
|
// the later call is the no-op it reads as.
|
||||||
|
ui_impl::install_css();
|
||||||
|
build_chooser_help(c);
|
||||||
|
|
||||||
|
// The chooser's own "(i)" layer and its own tour, both over the widgets the block above resolved.
|
||||||
|
// The tour's overlay wraps the whole window, so it is built before the window is presented.
|
||||||
|
chooser_help_sync(c);
|
||||||
|
build_chooser_tour(c);
|
||||||
|
|
||||||
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
|
g_signal_connect(c.list, "row-activated", G_CALLBACK(on_row_activated), &c);
|
||||||
g_signal_connect(c.wave_list, "row-activated", G_CALLBACK(on_wave_activated), &c);
|
g_signal_connect(c.wave_list, "row-activated", G_CALLBACK(on_wave_activated), &c);
|
||||||
g_signal_connect(c.waves_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c);
|
g_signal_connect(c.waves_open, "clicked", G_CALLBACK(on_wave_open_clicked), &c);
|
||||||
@@ -952,6 +1322,19 @@ bool choose_source(Config& cfg) {
|
|||||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_PICK_MS")) {
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_PICK_MS")) {
|
||||||
c.pick_source = g_timeout_add(static_cast<guint>(std::atoi(env)), pick_probe, &c);
|
c.pick_source = g_timeout_add(static_cast<guint>(std::atoi(env)), pick_probe, &c);
|
||||||
}
|
}
|
||||||
|
// A first run gets the tour here, on this window, before a source is picked: the chooser is the
|
||||||
|
// surface a first run meets first, and the window's own auto-start is unreachable until this call
|
||||||
|
// has returned with a source. 1.5 s is the window's own delay and for its reason -- the window has
|
||||||
|
// to be up and laid out before anything points at it -- and the walk's own finish sets
|
||||||
|
// `tour_pending`, which is how it continues into the application's window.
|
||||||
|
if (!load_settings().tour_seen) {
|
||||||
|
c.tour_source = g_timeout_add(1500, chooser_tour_autostart, &c);
|
||||||
|
}
|
||||||
|
// `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`: schedule the tour probe *after* the auto-start above, so the
|
||||||
|
// reading is of a tour the run itself began rather than one the probe started.
|
||||||
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_CHOOSER_TOUR_MS")) {
|
||||||
|
c.tour_probe_source = g_timeout_add(static_cast<guint>(std::atoi(env)), chooser_tour_probe, &c);
|
||||||
|
}
|
||||||
|
|
||||||
c.loop = g_main_loop_new(nullptr, FALSE);
|
c.loop = g_main_loop_new(nullptr, FALSE);
|
||||||
g_main_loop_run(c.loop);
|
g_main_loop_run(c.loop);
|
||||||
|
|||||||
+419
-68
@@ -982,6 +982,18 @@ void rebuild_trigger_signals(App& app) {
|
|||||||
g_object_unref(list);
|
g_object_unref(list);
|
||||||
|
|
||||||
TriggerConfig& tc = app.model->trigger();
|
TriggerConfig& tc = app.model->trigger();
|
||||||
|
// A session stores the trigger's channel by *name*: resolved here, where the acquired channels
|
||||||
|
// exist. The pending name is cleared either way, so a name this stream does not have falls through
|
||||||
|
// to the default below rather than being retried on every rebuild.
|
||||||
|
if (!app.pending_trigger_channel.empty()) {
|
||||||
|
for (std::size_t c = 0; c < channels.size(); ++c) {
|
||||||
|
if (channels[c].name == app.pending_trigger_channel) {
|
||||||
|
tc.channel = static_cast<int>(c);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.pending_trigger_channel.clear();
|
||||||
|
}
|
||||||
if (tc.channel < 0 && !channels.empty()) tc.channel = 0;
|
if (tc.channel < 0 && !channels.empty()) tc.channel = 0;
|
||||||
const int sel = tc.channel;
|
const int sel = tc.channel;
|
||||||
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_signal_drop),
|
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_signal_drop),
|
||||||
@@ -1990,6 +2002,11 @@ void open_math_dialog(App& app, int edit) {
|
|||||||
}
|
}
|
||||||
GtkWidget* window = d->window;
|
GtkWidget* window = d->window;
|
||||||
gtk_window_set_title(GTK_WINDOW(window), edit >= 0 ? "Edit math channel" : "New math channel");
|
gtk_window_set_title(GTK_WINDOW(window), edit >= 0 ? "Edit math channel" : "New math channel");
|
||||||
|
// The contextual-help "?" for this editor. This is the one surface whose heading only exists while a
|
||||||
|
// dialog is open -- the file is rebuilt on every opening -- so it cannot be a row the window's own
|
||||||
|
// build visits: the row is still the table's, and this states its host and asks for the hint.
|
||||||
|
app.math_head = head;
|
||||||
|
context_help_add(app, "math", head);
|
||||||
// The dialog's own state, freed with the window: a plain local here would leak the graph and every drag
|
// The dialog's own state, freed with the window: a plain local here would leak the graph and every drag
|
||||||
// it is halfway through on each opening.
|
// it is halfway through on each opening.
|
||||||
g_object_set_data_full(G_OBJECT(window), "udpscope-math-dialog", d,
|
g_object_set_data_full(G_OBJECT(window), "udpscope-math-dialog", d,
|
||||||
@@ -1998,7 +2015,13 @@ void open_math_dialog(App& app, int edit) {
|
|||||||
GtkWidget* dd = GTK_WIDGET(w);
|
GtkWidget* dd = GTK_WIDGET(w);
|
||||||
MathDialog* md = static_cast<MathDialog*>(
|
MathDialog* md = static_cast<MathDialog*>(
|
||||||
g_object_get_data(G_OBJECT(dd), "udpscope-math-dialog"));
|
g_object_get_data(G_OBJECT(dd), "udpscope-math-dialog"));
|
||||||
if (md != nullptr && md->app != nullptr) md->app->math_dialog = nullptr;
|
if (md != nullptr && md->app != nullptr) {
|
||||||
|
// The host goes with the window, and the builder that owns it is dropped on the
|
||||||
|
// next opening: clear the pointer here rather than leaving it aimed at widgets
|
||||||
|
// that are about to be finalized.
|
||||||
|
md->app->math_head = nullptr;
|
||||||
|
md->app->math_dialog = nullptr;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}),
|
}),
|
||||||
nullptr);
|
nullptr);
|
||||||
@@ -2299,6 +2322,9 @@ GtkWidget* build_measures_panel(App& app) {
|
|||||||
update_measures(*a, /*force=*/true, /*accumulate=*/true);
|
update_measures(*a, /*force=*/true, /*accumulate=*/true);
|
||||||
}),
|
}),
|
||||||
&app);
|
&app);
|
||||||
|
// The heading row -- the .ui's anonymous first child, carrying the title and the `+`. It is where
|
||||||
|
// the contextual help's "?" joins (see `ui_context_help.cpp`).
|
||||||
|
app.measures_head = gtk_widget_get_first_child(box);
|
||||||
return box;
|
return box;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2359,10 +2385,10 @@ void on_export_response(GObject* source, GAsyncResult* result, gpointer user) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The extension picks the writer, so the filter the user chose is honoured by way of
|
// The extension picks the writer, so the filter the user chose is honoured by way of
|
||||||
// the name it put in the entry rather than by a second piece of state.
|
// the name it put in the entry rather than by a second piece of state. `.npy` is what an
|
||||||
const bool csv = path.size() >= 4 && path.compare(path.size() - 4, 4, ".csv") == 0;
|
// unknown extension gets, which is the dialog's default filter too.
|
||||||
std::string error;
|
std::string error;
|
||||||
const bool ok = csv ? write_csv(path, ctx->table, error) : write_npy(path, ctx->table, error);
|
const bool ok = write_export(path, ctx->table, error);
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
set_notice(*ctx->app, "export failed: " + error);
|
set_notice(*ctx->app, "export failed: " + error);
|
||||||
return;
|
return;
|
||||||
@@ -2399,10 +2425,18 @@ void on_export_clicked(GtkButton*, gpointer user) {
|
|||||||
gtk_file_filter_set_name(csv, "CSV (*.csv)");
|
gtk_file_filter_set_name(csv, "CSV (*.csv)");
|
||||||
gtk_file_filter_add_pattern(csv, "*.csv");
|
gtk_file_filter_add_pattern(csv, "*.csv");
|
||||||
g_list_store_append(filters, csv);
|
g_list_store_append(filters, csv);
|
||||||
|
// MDF 3.30, which is what ScryLab (and every MDF reader) opens: offered by both of the
|
||||||
|
// extensions the format is stored under.
|
||||||
|
GtkFileFilter* mdf = gtk_file_filter_new();
|
||||||
|
gtk_file_filter_set_name(mdf, "MDF 3.30 (*.mf3, *.mdf)");
|
||||||
|
gtk_file_filter_add_pattern(mdf, "*.mf3");
|
||||||
|
gtk_file_filter_add_pattern(mdf, "*.mdf");
|
||||||
|
g_list_store_append(filters, mdf);
|
||||||
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
|
gtk_file_dialog_set_filters(dialog, G_LIST_MODEL(filters));
|
||||||
gtk_file_dialog_set_default_filter(dialog, npy);
|
gtk_file_dialog_set_default_filter(dialog, npy);
|
||||||
g_object_unref(npy);
|
g_object_unref(npy);
|
||||||
g_object_unref(csv);
|
g_object_unref(csv);
|
||||||
|
g_object_unref(mdf);
|
||||||
g_object_unref(filters);
|
g_object_unref(filters);
|
||||||
|
|
||||||
gtk_file_dialog_save(dialog, GTK_WINDOW(app->window), nullptr, on_export_response,
|
gtk_file_dialog_save(dialog, GTK_WINDOW(app->window), nullptr, on_export_response,
|
||||||
@@ -2410,32 +2444,70 @@ void on_export_clicked(GtkButton*, gpointer user) {
|
|||||||
g_object_unref(dialog);
|
g_object_unref(dialog);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dismisses the overflow menu. Every entry that opens something *else* -- a dialog, a window, the
|
||||||
|
/// tour -- does this first, or the menu is still up behind what it opened.
|
||||||
|
void close_overflow_menu(App& app) {
|
||||||
|
if (app.menu_button == nullptr) return;
|
||||||
|
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app.menu_button));
|
||||||
|
if (popover != nullptr) gtk_popover_popdown(popover);
|
||||||
|
}
|
||||||
|
|
||||||
void on_about_clicked(GtkButton*, gpointer user) {
|
void on_about_clicked(GtkButton*, gpointer user) {
|
||||||
App* app = static_cast<App*>(user);
|
App* app = static_cast<App*>(user);
|
||||||
if (app->menu_button != nullptr) {
|
close_overflow_menu(*app);
|
||||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
|
|
||||||
if (popover != nullptr) gtk_popover_popdown(popover);
|
|
||||||
}
|
|
||||||
show_about(GTK_WINDOW(app->window),
|
show_about(GTK_WINDOW(app->window),
|
||||||
layout_path(app->model->config().server, app->model->config().port));
|
layout_path(app->model->config().server, app->model->config().port));
|
||||||
}
|
}
|
||||||
|
|
||||||
void on_help_clicked(GtkButton*, gpointer user) {
|
void on_help_clicked(GtkButton*, gpointer user) {
|
||||||
App* app = static_cast<App*>(user);
|
App* app = static_cast<App*>(user);
|
||||||
if (app->menu_button != nullptr) {
|
close_overflow_menu(*app);
|
||||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
|
|
||||||
if (popover != nullptr) gtk_popover_popdown(popover);
|
|
||||||
}
|
|
||||||
show_help(GTK_WINDOW(app->window));
|
show_help(GTK_WINDOW(app->window));
|
||||||
}
|
}
|
||||||
|
|
||||||
void on_shortcuts_clicked(GtkButton*, gpointer user) {
|
void on_shortcuts_clicked(GtkButton*, gpointer user) {
|
||||||
App* app = static_cast<App*>(user);
|
App* app = static_cast<App*>(user);
|
||||||
|
close_overflow_menu(*app);
|
||||||
|
show_shortcuts(GTK_WINDOW(app->window));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The Help submenu's two rows beyond the dialogs. The view swap is the plot menu's own shape: the
|
||||||
|
/// second view *replaces* the first, with a Back row at its head, because a submenu torn out of the
|
||||||
|
/// menu it belongs to is a second thing to dismiss and a `GtkMenuButton` that owned one toggled
|
||||||
|
/// itself out of sync the moment anything else closed its popover.
|
||||||
|
///
|
||||||
|
/// What it does *not* do is rebuild the rows. The main view holds `app.layout_toggle`, which the tick
|
||||||
|
/// reads (`sync_layout_editor()`) and which the `L` accel moves, so a rebuild would leave that pointer
|
||||||
|
/// aimed at a destroyed widget; the two views are pages of a `GtkStack` instead, and a view is swapped
|
||||||
|
/// by naming the page.
|
||||||
|
void on_menu_show_help(GtkButton*, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
if (app->menu_stack == nullptr) return;
|
||||||
|
gtk_stack_set_visible_child_name(GTK_STACK(app->menu_stack), "help");
|
||||||
|
// The popover's content changed size, which is what `present()` re-allocates and re-positions: a
|
||||||
|
// popover that is already up does not re-measure itself.
|
||||||
if (app->menu_button != nullptr) {
|
if (app->menu_button != nullptr) {
|
||||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
|
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
|
||||||
if (popover != nullptr) gtk_popover_popdown(popover);
|
if (popover != nullptr) gtk_popover_present(popover);
|
||||||
}
|
}
|
||||||
show_shortcuts(GTK_WINDOW(app->window));
|
}
|
||||||
|
|
||||||
|
void on_menu_back(GtkButton*, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
if (app->menu_stack == nullptr) return;
|
||||||
|
gtk_stack_set_visible_child_name(GTK_STACK(app->menu_stack), "main");
|
||||||
|
if (app->menu_button != nullptr) {
|
||||||
|
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(app->menu_button));
|
||||||
|
if (popover != nullptr) gtk_popover_present(popover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Guided tour": the menu hands over to the tour module, and the tour is startable at any time --
|
||||||
|
/// from here, from `<Control>t`, and by itself on a first run.
|
||||||
|
void on_tour_clicked(GtkButton*, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
close_overflow_menu(*app);
|
||||||
|
start_tour(*app);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- right-click menu -------------------------------------------------------
|
// --- right-click menu -------------------------------------------------------
|
||||||
@@ -3243,6 +3315,11 @@ gboolean tick(gpointer user) {
|
|||||||
// no text and no name either.
|
// no text and no name either.
|
||||||
name_side_tabs(*app);
|
name_side_tabs(*app);
|
||||||
note_panel_controls(*app);
|
note_panel_controls(*app);
|
||||||
|
// The tour, re-measured every frame: the target's allocation is 0 on the frame it is first shown
|
||||||
|
// (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`) and the widget
|
||||||
|
// moves under the spotlight on a window resize, so a one-shot measurement is wrong both ways.
|
||||||
|
// Cheap when the tour is not running: one flag.
|
||||||
|
update_tour(*app);
|
||||||
ScopeModel& model = *app->model;
|
ScopeModel& model = *app->model;
|
||||||
const gint64 tick_start_us = g_get_monotonic_time();
|
const gint64 tick_start_us = g_get_monotonic_time();
|
||||||
|
|
||||||
@@ -3291,6 +3368,17 @@ gboolean tick(gpointer user) {
|
|||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The help layer's own readings and the bounds the help's annotated figures crop, on the same
|
||||||
|
// once-a-second cadence and for the same reason as the line above: the dump runs after the window
|
||||||
|
// is destroyed and `verification.md` forbids a `gtk_widget_*` call there.
|
||||||
|
{
|
||||||
|
static gint64 next_help_log = 0;
|
||||||
|
const gint64 now = g_get_monotonic_time();
|
||||||
|
if (now >= next_help_log) {
|
||||||
|
next_help_log = now + G_TIME_SPAN_SECOND;
|
||||||
|
capture_help_state(*app);
|
||||||
|
}
|
||||||
|
}
|
||||||
// Captured every frame so the dump has the last live value: see `dbg_measures_pane_w`.
|
// Captured every frame so the dump has the last live value: see `dbg_measures_pane_w`.
|
||||||
if (app->measures_list != nullptr) {
|
if (app->measures_list != nullptr) {
|
||||||
app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list);
|
app->dbg_measures_pane_w = gtk_widget_get_width(app->measures_list);
|
||||||
@@ -3357,10 +3445,24 @@ gboolean tick(gpointer user) {
|
|||||||
// window the session file holds, so a restore done before it would be overwritten by the saved
|
// window the session file holds, so a restore done before it would be overwritten by the saved
|
||||||
// value. The generation is the other half of the wait -- the stream's first CONFIG, which is what
|
// value. The generation is the other half of the wait -- the stream's first CONFIG, which is what
|
||||||
// gives the stored trigger channel a list to be found in.
|
// gives the stored trigger channel a list to be found in.
|
||||||
if (app->live_restore_pending && !app->replaying && !model.channels().empty() &&
|
// The wait is for the stream's own channel list, and the question it is really asking is whether the
|
||||||
model.generation() != app->live_restore_generation) {
|
// trigger's channel *name* can be resolved again: a file view that renamed its channels (`square (A)`)
|
||||||
app->live_restore_pending = false;
|
// has to be gone before the name means the signal it meant. Asking that directly, rather than watching
|
||||||
restore_live_state(*app);
|
// `generation()`, is what makes going live immediate in the common case -- a single event keeps the
|
||||||
|
// stream's own channel names, so its channel list is already the right one, and the generation of a
|
||||||
|
// restarted client whose CONFIG is identical is *deliberately* unchanged (`same_stream_signals()`), so
|
||||||
|
// the restore used to wait for something that was never going to happen. What it took to shake loose
|
||||||
|
// was the trigger toggle, which is a structural change and moved the generation -- the "toggle it a few
|
||||||
|
// times" this fixes.
|
||||||
|
if (app->live_restore_pending && !app->replaying && !model.channels().empty()) {
|
||||||
|
bool resolvable = app->live_state.trigger_channel.empty();
|
||||||
|
for (const ChannelInfo& c : model.channels()) {
|
||||||
|
if (c.name == app->live_state.trigger_channel) resolvable = true;
|
||||||
|
}
|
||||||
|
if (resolvable) {
|
||||||
|
app->live_restore_pending = false;
|
||||||
|
restore_live_state(*app);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// What the display shows, decided once for the whole frame: the producer can open or
|
// What the display shows, decided once for the whole frame: the producer can open or
|
||||||
@@ -3625,7 +3727,7 @@ gboolean tick(gpointer user) {
|
|||||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), trigger_on);
|
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), trigger_on);
|
||||||
}
|
}
|
||||||
const bool armed = model.trigger_armed().load(std::memory_order_relaxed);
|
const bool armed = model.trigger_armed().load(std::memory_order_relaxed);
|
||||||
gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm");
|
sync_arm_buttons(*app);
|
||||||
if (new_capture) {
|
if (new_capture) {
|
||||||
// The edge has been accepted, which means the pre window was already full, so the
|
// The edge has been accepted, which means the pre window was already full, so the
|
||||||
// whole event window can be framed now and the post side filled in view.
|
// whole event window can be framed now and the post side filled in view.
|
||||||
@@ -4070,7 +4172,7 @@ gboolean tick(gpointer user) {
|
|||||||
if (app->capturing) {
|
if (app->capturing) {
|
||||||
mode = "trigger: CAPTURING (post-trigger window filling)";
|
mode = "trigger: CAPTURING (post-trigger window filling)";
|
||||||
} else if (app->event_held) {
|
} else if (app->event_held) {
|
||||||
mode = "trigger: EVENT HELD (Arm to re-arm, or 'Go live' for streaming)";
|
mode = "trigger: EVENT HELD (Arm to re-arm, or switch the trigger off to stream)";
|
||||||
} else if (armed && have_trigger_value) {
|
} else if (armed && have_trigger_value) {
|
||||||
static char waiting[192];
|
static char waiting[192];
|
||||||
std::snprintf(waiting, sizeof(waiting),
|
std::snprintf(waiting, sizeof(waiting),
|
||||||
@@ -4388,6 +4490,64 @@ void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user) {
|
|||||||
sync_panel_switches(*app);
|
sync_panel_switches(*app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Both panes' modes, applied together: the trigger's body against its summary line, the two [edit]
|
||||||
|
/// buttons against what they would do, and the signal list rebuilt because the lock decides what a row
|
||||||
|
/// carries. Idempotent, so a caller only flips a flag and calls this.
|
||||||
|
void apply_pane_lock(App& app) {
|
||||||
|
if (app.trigger_body != nullptr) gtk_widget_set_visible(app.trigger_body, !app.trigger_locked);
|
||||||
|
if (app.trigger_summary != nullptr) {
|
||||||
|
gtk_widget_set_visible(app.trigger_summary, app.trigger_locked);
|
||||||
|
}
|
||||||
|
const auto edit_button = [](GtkWidget* button, bool locked, const char* what) {
|
||||||
|
if (button == nullptr) return;
|
||||||
|
// Both names must be ones Adwaita *has*: `emblem-ok-symbolic` exists only in breeze, so the "done"
|
||||||
|
// button was blank -- and a blank icon button is invisible in every other reading, which is why
|
||||||
|
// `pane edits:` checks these two by name and verdict.
|
||||||
|
gtk_button_set_icon_name(GTK_BUTTON(button),
|
||||||
|
locked ? "document-edit-symbolic" : "object-select-symbolic");
|
||||||
|
const std::string tip = std::string(locked ? "configure " : "keep this configuration of ") + what;
|
||||||
|
gtk_widget_set_tooltip_text(button, tip.c_str());
|
||||||
|
};
|
||||||
|
edit_button(app.trigger_edit, app.trigger_locked, "the trigger");
|
||||||
|
edit_button(app.signals_edit, app.signals_locked, "the signals");
|
||||||
|
// The lock decides whether the compact line's arm control is the one on screen: with the body shown the
|
||||||
|
// body's own button is there, and two arm buttons at once would be two controls for one state.
|
||||||
|
sync_arm_buttons(app);
|
||||||
|
sync_trigger_summary(app);
|
||||||
|
// The list, because the lock is what decides whether a row carries a tick, a menu and the `+`.
|
||||||
|
if (app.signal_list != nullptr) rebuild_signal_panels(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Either pane's [edit] button: one handler, because the two differ only in which flag they flip.
|
||||||
|
void on_pane_edit_clicked(GtkButton* button, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
if (GTK_WIDGET(button) == app->trigger_edit) {
|
||||||
|
app->trigger_locked = !app->trigger_locked;
|
||||||
|
} else if (GTK_WIDGET(button) == app->signals_edit) {
|
||||||
|
app->signals_locked = !app->signals_locked;
|
||||||
|
}
|
||||||
|
apply_pane_lock(*app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hides the sidebar for the length of a drag, when it is *overlaid*, and restores it after.
|
||||||
|
///
|
||||||
|
/// With the split view collapsed the sidebar covers the plot -- that is what collapsed means -- so a drag
|
||||||
|
/// that starts in the signal list has nowhere to land: the pointer is over the sidebar and never over a
|
||||||
|
/// drop target, and the drag is impossible rather than merely awkward. Side by side nothing is done: the
|
||||||
|
/// plot is already visible, and hiding the list the drag started in would be startling.
|
||||||
|
void row_drag_sidebar(App& app, bool dragging) {
|
||||||
|
if (app.side_view == nullptr) return;
|
||||||
|
AdwOverlaySplitView* split = ADW_OVERLAY_SPLIT_VIEW(app.side_view);
|
||||||
|
if (dragging) {
|
||||||
|
if (!adw_overlay_split_view_get_collapsed(split)) return;
|
||||||
|
app.sidebar_hidden_for_drag = adw_overlay_split_view_get_show_sidebar(split) != FALSE;
|
||||||
|
if (app.sidebar_hidden_for_drag) adw_overlay_split_view_set_show_sidebar(split, FALSE);
|
||||||
|
} else if (app.sidebar_hidden_for_drag) {
|
||||||
|
app.sidebar_hidden_for_drag = false;
|
||||||
|
adw_overlay_split_view_set_show_sidebar(split, TRUE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void run_app_action(App& app, AppAction action) {
|
void run_app_action(App& app, AppAction action) {
|
||||||
const auto toggle = [](GtkWidget* button) {
|
const auto toggle = [](GtkWidget* button) {
|
||||||
if (button == nullptr) return;
|
if (button == nullptr) return;
|
||||||
@@ -4424,6 +4584,14 @@ void run_app_action(App& app, AppAction action) {
|
|||||||
case AppAction::Shortcuts:
|
case AppAction::Shortcuts:
|
||||||
show_shortcuts(GTK_WINDOW(app.window));
|
show_shortcuts(GTK_WINDOW(app.window));
|
||||||
break;
|
break;
|
||||||
|
case AppAction::GuidedTour:
|
||||||
|
start_tour(app);
|
||||||
|
break;
|
||||||
|
case AppAction::ContextualHelp:
|
||||||
|
// Through the menu's own row rather than around it: the check *is* the setting, and the row's
|
||||||
|
// handler is the one place that writes it.
|
||||||
|
toggle(app.contextual_toggle);
|
||||||
|
break;
|
||||||
case AppAction::Quit:
|
case AppAction::Quit:
|
||||||
if (app.window != nullptr) gtk_window_close(GTK_WINDOW(app.window));
|
if (app.window != nullptr) gtk_window_close(GTK_WINDOW(app.window));
|
||||||
break;
|
break;
|
||||||
@@ -4436,6 +4604,12 @@ gboolean on_app_key_pressed(GtkEventControllerKey*, guint keyval, guint, GdkModi
|
|||||||
gpointer user) {
|
gpointer user) {
|
||||||
App* app = static_cast<App*>(user);
|
App* app = static_cast<App*>(user);
|
||||||
if (app->window == nullptr) return FALSE;
|
if (app->window == nullptr) return FALSE;
|
||||||
|
// Escape ends the tour, and it does so ahead of the editable check: a tour that cannot be dismissed
|
||||||
|
// with the key every one of them teaches is a tour with a trap in it.
|
||||||
|
if (keyval == GDK_KEY_Escape && tour_active(*app)) {
|
||||||
|
tour_dismiss(*app);
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window));
|
GtkWidget* focus = gtk_window_get_focus(GTK_WINDOW(app->window));
|
||||||
if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE;
|
if (focus != nullptr && GTK_IS_EDITABLE(focus)) return FALSE;
|
||||||
|
|
||||||
@@ -4497,6 +4671,9 @@ GtkWidget* build_toolbar(App& app) {
|
|||||||
// has already packed, so a control added here would land right of the whole group.
|
// has already packed, so a control added here would land right of the whole group.
|
||||||
if (GtkWidget* slot = GTK_WIDGET(gtk_builder_get_object(ui, "window_control_slot"))) {
|
if (GtkWidget* slot = GTK_WIDGET(gtk_builder_get_object(ui, "window_control_slot"))) {
|
||||||
gtk_box_append(GTK_BOX(slot), build_window_control(app));
|
gtk_box_append(GTK_BOX(slot), build_window_control(app));
|
||||||
|
// Where the contextual help's "?" for the span control joins: the slot, not the bar, because a
|
||||||
|
// control added to a header bar lands right of everything the file packed.
|
||||||
|
app.window_control_slot = slot;
|
||||||
}
|
}
|
||||||
refresh_window_limits(app);
|
refresh_window_limits(app);
|
||||||
|
|
||||||
@@ -4516,8 +4693,9 @@ GtkWidget* build_toolbar(App& app) {
|
|||||||
// because these act on the `RowSet` directly and two of them are toggles whose state the
|
// because these act on the `RowSet` directly and two of them are toggles whose state the
|
||||||
// rest of the UI reads, so an action model would only add a second copy of that state.
|
// rest of the UI reads, so an action model would only add a second copy of that state.
|
||||||
//
|
//
|
||||||
// The order is a GNOME menu's: what you do to the *view*, then the data, then the help
|
// The order is a GNOME menu's: what you do to the *view*, then the data, then Help, then the
|
||||||
// trio. Nothing here needs a separator of its own -- the groups are the separators.
|
// two about-the-program entries. Nothing here needs a separator of its own -- the groups are
|
||||||
|
// the separators.
|
||||||
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
GtkWidget* menu_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
gtk_widget_set_margin_top(menu_box, 6);
|
gtk_widget_set_margin_top(menu_box, 6);
|
||||||
gtk_widget_set_margin_bottom(menu_box, 6);
|
gtk_widget_set_margin_bottom(menu_box, 6);
|
||||||
@@ -4557,16 +4735,19 @@ GtkWidget* build_toolbar(App& app) {
|
|||||||
|
|
||||||
gtk_box_append(GTK_BOX(menu_box), menu_separator());
|
gtk_box_append(GTK_BOX(menu_box), menu_separator());
|
||||||
|
|
||||||
GtkWidget* shortcuts_item = menu_button("Keyboard shortcuts", "Ctrl+/");
|
// The Help submenu's door. A row and not a `GtkMenuButton`: the submenu is the *next view of this
|
||||||
gtk_widget_set_tooltip_text(shortcuts_item,
|
// menu*, and the chevron is what says so -- the same shape, and the same reason, as the plot menu's
|
||||||
"every key and mouse gesture, including the plot's own");
|
// Remove-signal row.
|
||||||
g_signal_connect(shortcuts_item, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
|
GtkWidget* help_row = menu_button("Help", nullptr);
|
||||||
gtk_box_append(GTK_BOX(menu_box), shortcuts_item);
|
gtk_widget_set_tooltip_text(help_row,
|
||||||
|
"the shortcut list, the documentation, a guided tour and the "
|
||||||
GtkWidget* help_item = menu_button("Help", "F1");
|
"contextual help");
|
||||||
gtk_widget_set_tooltip_text(help_item, "the paged guide to every feature");
|
g_signal_connect(help_row, "clicked", G_CALLBACK(on_menu_show_help), &app);
|
||||||
g_signal_connect(help_item, "clicked", G_CALLBACK(on_help_clicked), &app);
|
if (GtkWidget* inner = gtk_button_get_child(GTK_BUTTON(help_row))) {
|
||||||
gtk_box_append(GTK_BOX(menu_box), help_item);
|
gtk_box_append(GTK_BOX(inner), gtk_image_new_from_icon_name("pan-end-symbolic"));
|
||||||
|
}
|
||||||
|
app.menu_help_row = help_row;
|
||||||
|
gtk_box_append(GTK_BOX(menu_box), help_row);
|
||||||
|
|
||||||
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
|
GtkWidget* prefs_item = menu_button("Preferences\u2026", nullptr);
|
||||||
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
g_signal_connect(prefs_item, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
@@ -4580,6 +4761,61 @@ GtkWidget* build_toolbar(App& app) {
|
|||||||
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
|
g_signal_connect(about_item, "clicked", G_CALLBACK(on_about_clicked), &app);
|
||||||
gtk_box_append(GTK_BOX(menu_box), about_item);
|
gtk_box_append(GTK_BOX(menu_box), about_item);
|
||||||
|
|
||||||
|
// --- the Help view, the menu's second page --------------------------------
|
||||||
|
// Exactly the four entries the menu promises: the two dialogs that already existed, the tour, and
|
||||||
|
// the switch for the "?" layer. The two dialogs are the *same* entry points the keys reach --
|
||||||
|
// `show_shortcuts()` and `show_help()` -- so the menu and the accelerator cannot drift apart.
|
||||||
|
GtkWidget* help_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
|
gtk_widget_set_margin_top(help_box, 6);
|
||||||
|
gtk_widget_set_margin_bottom(help_box, 6);
|
||||||
|
gtk_widget_set_margin_start(help_box, 6);
|
||||||
|
gtk_widget_set_margin_end(help_box, 6);
|
||||||
|
|
||||||
|
GtkWidget* back_row = menu_icon_button("go-previous-symbolic", "Back");
|
||||||
|
g_signal_connect(back_row, "clicked", G_CALLBACK(on_menu_back), &app);
|
||||||
|
app.menu_back_row = back_row;
|
||||||
|
gtk_box_append(GTK_BOX(help_box), back_row);
|
||||||
|
gtk_box_append(GTK_BOX(help_box), menu_separator());
|
||||||
|
|
||||||
|
app.menu_shortcuts_row = menu_button("Keyboard shortcuts", "Ctrl+/");
|
||||||
|
gtk_widget_set_tooltip_text(app.menu_shortcuts_row,
|
||||||
|
"every key and mouse gesture, including the plot's own");
|
||||||
|
g_signal_connect(app.menu_shortcuts_row, "clicked", G_CALLBACK(on_shortcuts_clicked), &app);
|
||||||
|
gtk_box_append(GTK_BOX(help_box), app.menu_shortcuts_row);
|
||||||
|
|
||||||
|
app.menu_docs_row = menu_button("Documentation", "F1");
|
||||||
|
gtk_widget_set_tooltip_text(app.menu_docs_row,
|
||||||
|
"the udpscope manual, opened in the desktop help viewer");
|
||||||
|
g_signal_connect(app.menu_docs_row, "clicked", G_CALLBACK(on_help_clicked), &app);
|
||||||
|
gtk_box_append(GTK_BOX(help_box), app.menu_docs_row);
|
||||||
|
|
||||||
|
app.menu_tour_row = menu_button("Guided tour", "Ctrl+T");
|
||||||
|
gtk_widget_set_tooltip_text(app.menu_tour_row,
|
||||||
|
"walk over the plot, the side panel, the trigger, the signals and "
|
||||||
|
"the status line, one step at a time");
|
||||||
|
g_signal_connect(app.menu_tour_row, "clicked", G_CALLBACK(on_tour_clicked), &app);
|
||||||
|
gtk_box_append(GTK_BOX(help_box), app.menu_tour_row);
|
||||||
|
|
||||||
|
// The check is the setting: `menu_toggle()` states it before connecting, so the row that starts
|
||||||
|
// checked does not fire the handler while the surfaces it acts on are still being built.
|
||||||
|
app.contextual_toggle = menu_toggle("Enable contextual help", nullptr,
|
||||||
|
app.settings.contextual_help);
|
||||||
|
gtk_widget_set_tooltip_text(app.contextual_toggle,
|
||||||
|
"a small \u201c?\u201d beside each surface, saying what that surface "
|
||||||
|
"is for");
|
||||||
|
g_signal_connect(app.contextual_toggle, "toggled", G_CALLBACK(on_contextual_help_toggled), &app);
|
||||||
|
gtk_box_append(GTK_BOX(help_box), app.contextual_toggle);
|
||||||
|
|
||||||
|
// The two views, as the pages of a stack: the popover's child is the stack and the visible child is
|
||||||
|
// the view. Swapping the *page* rather than rebuilding the box is what keeps `app.layout_toggle` --
|
||||||
|
// which points into the main view and which the tick reads -- a live widget.
|
||||||
|
app.menu_main_view = menu_box;
|
||||||
|
app.menu_help_view = help_box;
|
||||||
|
app.menu_stack = gtk_stack_new();
|
||||||
|
gtk_stack_set_transition_type(GTK_STACK(app.menu_stack), GTK_STACK_TRANSITION_TYPE_NONE);
|
||||||
|
gtk_stack_add_named(GTK_STACK(app.menu_stack), menu_box, "main");
|
||||||
|
gtk_stack_add_named(GTK_STACK(app.menu_stack), help_box, "help");
|
||||||
|
|
||||||
// --- the pane toggles, which live on the bar rather than in the menu ------
|
// --- the pane toggles, which live on the bar rather than in the menu ------
|
||||||
// The three pane toggles, and the rest of the bar, are the file's now; their handlers are wired where
|
// The three pane toggles, and the rest of the bar, are the file's now; their handlers are wired where
|
||||||
// the bar's ids are resolved, below. The popover above still shares this function's styling helpers
|
// the bar's ids are resolved, below. The popover above still shares this function's styling helpers
|
||||||
@@ -4587,7 +4823,7 @@ GtkWidget* build_toolbar(App& app) {
|
|||||||
|
|
||||||
GtkWidget* popover = gtk_popover_new();
|
GtkWidget* popover = gtk_popover_new();
|
||||||
gtk_widget_add_css_class(popover, "udpscope-menu");
|
gtk_widget_add_css_class(popover, "udpscope-menu");
|
||||||
gtk_popover_set_child(GTK_POPOVER(popover), menu_box);
|
gtk_popover_set_child(GTK_POPOVER(popover), app.menu_stack);
|
||||||
app.menu_button = GTK_WIDGET(gtk_builder_get_object(ui, "menu_button"));
|
app.menu_button = GTK_WIDGET(gtk_builder_get_object(ui, "menu_button"));
|
||||||
app.sidebar_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "sidebar_toggle"));
|
app.sidebar_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "sidebar_toggle"));
|
||||||
app.trigger_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_toggle"));
|
app.trigger_toggle = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_toggle"));
|
||||||
@@ -4642,6 +4878,9 @@ void on_activate(GtkApplication* application, gpointer user) {
|
|||||||
app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge"));
|
app->status_badge = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_badge"));
|
||||||
app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label"));
|
app->status_label = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_label"));
|
||||||
app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info"));
|
app->status_info_button = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_info"));
|
||||||
|
// The status line's row, which is where the status line's own "?" joins and one of the surfaces the
|
||||||
|
// help's annotated figures label.
|
||||||
|
app->status_row = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "status_row"));
|
||||||
GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column"));
|
GtkWidget* side = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_column"));
|
||||||
GtkWidget* side_stack = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_stack"));
|
GtkWidget* side_stack = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_stack"));
|
||||||
GtkWidget* side_switcher = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_switcher"));
|
GtkWidget* side_switcher = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "side_switcher"));
|
||||||
@@ -4652,6 +4891,7 @@ void on_activate(GtkApplication* application, gpointer user) {
|
|||||||
if (app->window == nullptr || side_view == nullptr || events_divider == nullptr ||
|
if (app->window == nullptr || side_view == nullptr || events_divider == nullptr ||
|
||||||
app->plot_box == nullptr || app->plot_split == nullptr || app->status_badge == nullptr ||
|
app->plot_box == nullptr || app->plot_split == nullptr || app->status_badge == nullptr ||
|
||||||
app->status_label == nullptr || app->status_info_button == nullptr ||
|
app->status_label == nullptr || app->status_info_button == nullptr ||
|
||||||
|
app->status_row == nullptr ||
|
||||||
side == nullptr || side_stack == nullptr || side_switcher == nullptr ||
|
side == nullptr || side_stack == nullptr || side_switcher == nullptr ||
|
||||||
measure_page == nullptr || signals_page == nullptr) {
|
measure_page == nullptr || signals_page == nullptr) {
|
||||||
return;
|
return;
|
||||||
@@ -4677,6 +4917,19 @@ void on_activate(GtkApplication* application, gpointer user) {
|
|||||||
// shows are the window's, and those are in the file.
|
// shows are the window's, and those are in the file.
|
||||||
gtk_window_set_titlebar(GTK_WINDOW(app->window), build_toolbar(*app));
|
gtk_window_set_titlebar(GTK_WINDOW(app->window), build_toolbar(*app));
|
||||||
|
|
||||||
|
// The guided tour's overlay goes *above* the window's content, between the window and the split
|
||||||
|
// view: its children -- the four dim rectangles, the spotlight's outline and the card -- have to
|
||||||
|
// be able to cover the whole content, and a widget placed anywhere inside the split view could
|
||||||
|
// only ever cover its own half of it. The split view keeps its reference from the builder, so
|
||||||
|
// handing the window a new child and then adopting the old one costs no reference bookkeeping.
|
||||||
|
{
|
||||||
|
GtkWidget* overlay = gtk_overlay_new();
|
||||||
|
gtk_window_set_child(GTK_WINDOW(app->window), overlay);
|
||||||
|
gtk_overlay_set_child(GTK_OVERLAY(overlay), side_view);
|
||||||
|
app->tour.overlay = overlay;
|
||||||
|
build_tour(*app);
|
||||||
|
}
|
||||||
|
|
||||||
// The size the window opens at, stated in code as well as in the `.ui`: `default-width` there is the
|
// The size the window opens at, stated in code as well as in the `.ui`: `default-width` there is the
|
||||||
// size it *asks* for, and a window whose content has a larger minimum opens at that minimum instead --
|
// size it *asks* for, and a window whose content has a larger minimum opens at that minimum instead --
|
||||||
// 756 px here, the plot column's own minimum plus the sidebar's 320. The old code set it explicitly;
|
// 756 px here, the plot column's own minimum plus the sidebar's 320. The old code set it explicitly;
|
||||||
@@ -4804,12 +5057,14 @@ void on_activate(GtkApplication* application, gpointer user) {
|
|||||||
app->signal_panel = build_signal_panel(*app);
|
app->signal_panel = build_signal_panel(*app);
|
||||||
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
|
gtk_box_append(GTK_BOX(signals_page), app->signal_panel);
|
||||||
|
|
||||||
// Both instruments start off, so both *bodies* start insensitive -- stated here by the one place
|
// The cursors start off, so their readout starts insensitive -- stated here by the one place that
|
||||||
// that knows the default, rather than left to whichever widget happened to be built first. The
|
// knows the default, and the *body* and not the panel, because the panel's heading carries the
|
||||||
// bodies and not the panels: each panel's heading carries the switch that turns it on, and a
|
// switch that turns it back on. The trigger's body is deliberately *not* disabled: a trigger is
|
||||||
// control inside an insensitive parent cannot be clicked.
|
// configured before it is switched on, so its settings have to stay editable while it is off.
|
||||||
gtk_widget_set_sensitive(app->trigger_body, FALSE);
|
|
||||||
gtk_widget_set_sensitive(app->cursors_body, FALSE);
|
gtk_widget_set_sensitive(app->cursors_body, FALSE);
|
||||||
|
// The arm controls are the one part of the trigger pane that needs trigger mode running: both of them,
|
||||||
|
// and the compact line's visibility, are stated from the toggle by `sync_arm_buttons()`.
|
||||||
|
sync_arm_buttons(*app);
|
||||||
gtk_widget_set_sensitive(app->cursors_reset, FALSE);
|
gtk_widget_set_sensitive(app->cursors_reset, FALSE);
|
||||||
|
|
||||||
if (app->side_stack != nullptr) {
|
if (app->side_stack != nullptr) {
|
||||||
@@ -4880,6 +5135,23 @@ void on_activate(GtkApplication* application, gpointer user) {
|
|||||||
rebuild_signal_panels(*app);
|
rebuild_signal_panels(*app);
|
||||||
g_timeout_add(kTickMs, tick, app);
|
g_timeout_add(kTickMs, tick, app);
|
||||||
|
|
||||||
|
// The contextual-help layer, once every surface it names exists. It reads the setting, so a user
|
||||||
|
// who turned it off meets a window with no "?" on it and a menu whose row is unchecked.
|
||||||
|
context_help_sync(*app);
|
||||||
|
|
||||||
|
// A first run is the run whose settings file does not say the tour has been offered -- see
|
||||||
|
// `tour_autostart()`. Delayed rather than fired on the first tick: the window has to be up and
|
||||||
|
// laid out for the tour to have anything to point at, and a tour that opened on the frame the
|
||||||
|
// window appeared would be a spotlight on a widget with no allocation yet. The source chooser's
|
||||||
|
// own "Guided tour" entry arrives as `Config::tour_pending` (the request has to outlive the dialog
|
||||||
|
// that made it -- the tour is drawn on *this* window) and gets the same delay for the same reason,
|
||||||
|
// through its own callback so the two are told apart in the readings.
|
||||||
|
if (app->model->config().tour_pending) {
|
||||||
|
g_timeout_add(1500, tour_start_requested, app);
|
||||||
|
} else if (!app->settings.tour_seen) {
|
||||||
|
g_timeout_add(1500, tour_autostart, app);
|
||||||
|
}
|
||||||
|
|
||||||
// Bubble phase on purpose: a focused GtkEditable or GtkButton wins its own keys, so typing
|
// Bubble phase on purpose: a focused GtkEditable or GtkButton wins its own keys, so typing
|
||||||
// 'm' into the window entry does not toggle the measurements pane and Space does not
|
// 'm' into the window entry does not toggle the measurements pane and Space does not
|
||||||
// double-fire on a focused button.
|
// double-fire on a focused button.
|
||||||
@@ -5367,14 +5639,42 @@ void collect_spins(GtkWidget* w, std::vector<double>* out) {
|
|||||||
/// checks the GtkEditable refusal. It goes through run_app_action() for the actions and
|
/// checks the GtkEditable refusal. It goes through run_app_action() for the actions and
|
||||||
/// through the real handler for the refusal, because the refusal lives in the handler.
|
/// through the real handler for the refusal, because the refusal lives in the handler.
|
||||||
|
|
||||||
/// Loads and validates every help page, opens all three windows, closes them, and reports.
|
/// Checks that the manual is present in the tree where the build says it is (`topics` and
|
||||||
/// `parsed` must equal `pages` and no page may be empty; `closed ok` with no GTK critical
|
/// `index`, both zero if it is missing), opens the About and shortcut dialogs, reads them and
|
||||||
/// is the crash check for three windows built and destroyed in one run.
|
/// closes them. The manual itself opens in a separate viewer process, so there is no help
|
||||||
|
/// window here to build; `closed ok` with no GTK critical is the crash check for the two
|
||||||
|
/// dialogs built and destroyed in one run.
|
||||||
|
|
||||||
|
|
||||||
|
/// GTK writes its own log messages -- `Trying to measure GtkBox \u2026 for width of N, but it needs at
|
||||||
|
/// least M` is one -- from inside a call of ours, so they reach stderr and never a widget: no probe
|
||||||
|
/// can read one, and the class is otherwise invisible to the self-check. The exit dump therefore
|
||||||
|
/// carries a count of them, which is the one reading that can assert the class away (`gtk warnings:
|
||||||
|
/// 0`). GTK is built with structured logging, so its messages go through the log *writer* and not
|
||||||
|
/// through the handler API (`g_log_set_handler()` never sees them); the writer is chained to rather
|
||||||
|
/// than replaced, so every message still reaches stderr exactly as before and the documented
|
||||||
|
/// baselines are read the same way they always were.
|
||||||
|
int g_gtk_warning_count = 0;
|
||||||
|
GLogWriterOutput count_gtk_log(GLogLevelFlags level, const GLogField* fields, gsize n_fields,
|
||||||
|
gpointer user_data) {
|
||||||
|
for (gsize i = 0; i < n_fields; ++i) {
|
||||||
|
if (g_strcmp0(fields[i].key, "GLIB_DOMAIN") != 0) continue;
|
||||||
|
if (g_strcmp0(static_cast<const char*>(fields[i].value), "Gtk") == 0 &&
|
||||||
|
(level & (G_LOG_LEVEL_WARNING | G_LOG_LEVEL_CRITICAL | G_LOG_LEVEL_ERROR)) != 0) {
|
||||||
|
++g_gtk_warning_count;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return g_log_writer_default(level, fields, n_fields, user_data);
|
||||||
|
}
|
||||||
|
|
||||||
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
|
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
|
||||||
RowSet& rows) {
|
RowSet& rows) {
|
||||||
(void)argc;
|
(void)argc;
|
||||||
|
// Count GTK's own log messages for the run (see `count_gtk_log`). Installed before anything can
|
||||||
|
// log -- and it must be: a log writer function may only be installed once, and GTK does not install
|
||||||
|
// one, so this is the whole of the run's logging rather than a replacement.
|
||||||
|
g_log_set_writer_func(count_gtk_log, nullptr, nullptr);
|
||||||
// Installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog`
|
// Installs libadwaita's stylesheet, which the shortcut overview's `AdwShortcutsDialog`
|
||||||
// needs to be drawn at all. Idempotent, and `choose_source()` may already have run it.
|
// needs to be drawn at all. Idempotent, and `choose_source()` may already have run it.
|
||||||
adw_init();
|
adw_init();
|
||||||
@@ -5505,6 +5805,12 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
|||||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELP_MS")) {
|
||||||
g_timeout_add(static_cast<guint>(std::atoi(env)), help_probe, &app);
|
g_timeout_add(static_cast<guint>(std::atoi(env)), help_probe, &app);
|
||||||
}
|
}
|
||||||
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_HELPUI_MS")) {
|
||||||
|
helpui_probe_next(app, 0, static_cast<guint>(std::atoi(env)));
|
||||||
|
}
|
||||||
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_TOUR_SURFACE_MS")) {
|
||||||
|
tour_surface_probe_next(app, static_cast<guint>(std::atoi(env)));
|
||||||
|
}
|
||||||
if (const char* env = g_getenv("UDPSCOPE_PROBE_CONFIGS_MS")) {
|
if (const char* env = g_getenv("UDPSCOPE_PROBE_CONFIGS_MS")) {
|
||||||
g_timeout_add(static_cast<guint>(std::atoi(env)), configs_probe, &app);
|
g_timeout_add(static_cast<guint>(std::atoi(env)), configs_probe, &app);
|
||||||
}
|
}
|
||||||
@@ -5677,23 +5983,34 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
|||||||
// list the file gives it. A `.ui` that lost one of them reads here -- `labels=1` or `list=0` -- rather
|
// list the file gives it. A `.ui` that lost one of them reads here -- `labels=1` or `list=0` -- rather
|
||||||
// than showing as a panel quietly missing its hint.
|
// than showing as a panel quietly missing its hint.
|
||||||
GtkWidget* panel = app.signal_list != nullptr ? gtk_widget_get_parent(app.signal_list) : nullptr;
|
GtkWidget* panel = app.signal_list != nullptr ? gtk_widget_get_parent(app.signal_list) : nullptr;
|
||||||
|
// Counted over the panel's *subtree*, not its direct children: the heading lives in a heading row
|
||||||
|
// beside the pane's [edit] button, and a count of children would read 1 whatever the file lost.
|
||||||
int labels = 0;
|
int labels = 0;
|
||||||
int lists = 0;
|
int lists = 0;
|
||||||
for (GtkWidget* c = panel != nullptr ? gtk_widget_get_first_child(panel) : nullptr; c != nullptr;
|
const std::function<void(GtkWidget*)> count = [&](GtkWidget* w) {
|
||||||
c = gtk_widget_get_next_sibling(c)) {
|
// The list stops the walk: its rows are the code's, and this reading is about the file's frame.
|
||||||
if (GTK_IS_LABEL(c)) ++labels;
|
if (GTK_IS_LIST_BOX(w)) {
|
||||||
if (GTK_IS_LIST_BOX(c)) ++lists;
|
++lists;
|
||||||
}
|
return;
|
||||||
|
}
|
||||||
|
if (GTK_IS_LABEL(w)) ++labels;
|
||||||
|
for (GtkWidget* c = gtk_widget_get_first_child(w); c != nullptr;
|
||||||
|
c = gtk_widget_get_next_sibling(c)) {
|
||||||
|
count(c);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (panel != nullptr) count(panel);
|
||||||
// The list's shape, beside the frame: `rows` is what the list holds, `signals` the streamed
|
// The list's shape, beside the frame: `rows` is what the list holds, `signals` the streamed
|
||||||
// ones, and the `MATH` divider plus the math channels account for the rest -- so
|
// ones, and the `MATH` divider plus the math channels account for the rest -- so
|
||||||
// `rows == signals + 1 + math` is the invariant, and `divider=0` is a list that lost its `+`.
|
// `rows == signals + 1 + math` is the invariant, and `divider=0` is a list that lost its `+`.
|
||||||
std::printf("[udpscope] signals panel: labels=%d list=%d rows=%d signals=%zu math=%zu "
|
std::printf("[udpscope] signals panel: labels=%d list=%d rows=%d signals=%zu math=%zu "
|
||||||
"divider=%d\n",
|
"divider=%d drag_sources=%d\n",
|
||||||
labels, lists, app.dbg_signal_rows, app.dbg_signal_plain, app.math.size(),
|
labels, lists, app.dbg_signal_rows, app.dbg_signal_plain, app.math.size(),
|
||||||
app.dbg_signal_rows ==
|
app.dbg_signal_rows ==
|
||||||
static_cast<int>(app.dbg_signal_plain + 1 + app.math.size())
|
static_cast<int>(app.dbg_signal_plain + 1 + app.math.size())
|
||||||
? 1
|
? 1
|
||||||
: 0);
|
: 0,
|
||||||
|
app.dbg_signal_drag_sources);
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
// The events pane's frame, read from the window: the four widgets the `.ui` gives it, and the list
|
// The events pane's frame, read from the window: the four widgets the `.ui` gives it, and the list
|
||||||
@@ -5720,6 +6037,22 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
|||||||
app.dbg_trigger_sensitive, app.dbg_switcher_visible,
|
app.dbg_trigger_sensitive, app.dbg_switcher_visible,
|
||||||
rows.cursors_visible() ? 1 : 0, rows.time_window(),
|
rows.cursors_visible() ? 1 : 0, rows.time_window(),
|
||||||
app.window_limit_s);
|
app.window_limit_s);
|
||||||
|
{
|
||||||
|
GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default());
|
||||||
|
std::printf("[udpscope] pane drag: sidebar hidden for the drag=%d restored after it=%d\n",
|
||||||
|
app.dbg_drag_hid_sidebar, app.dbg_drag_restored_sidebar);
|
||||||
|
std::printf("[udpscope] pane edits: locked=%s(%s) config=%s(%s)\n",
|
||||||
|
app.dbg_pane_edit_icon.c_str(),
|
||||||
|
gtk_icon_theme_has_icon(theme, app.dbg_pane_edit_icon.c_str()) ? "ok" : "MISSING",
|
||||||
|
app.dbg_pane_view_icon.c_str(),
|
||||||
|
gtk_icon_theme_has_icon(theme, app.dbg_pane_view_icon.c_str()) ? "ok" : "MISSING");
|
||||||
|
}
|
||||||
|
std::printf("[udpscope] panes: trigger=%s signals=%s body_visible=%d summary='%s'\n",
|
||||||
|
app.dbg_trigger_locked ? "locked" : "config",
|
||||||
|
app.dbg_signals_locked ? "locked" : "config", app.dbg_trigger_body_visible,
|
||||||
|
app.dbg_trigger_summary.c_str());
|
||||||
|
std::printf("[udpscope] panes: the same line as the probe found it: '%s'\n",
|
||||||
|
app.dbg_trigger_summary_early.c_str());
|
||||||
std::printf("[udpscope] panel switches: trigger=%d/%d cursors=%d/%d drove_toolbar=%d "
|
std::printf("[udpscope] panel switches: trigger=%d/%d cursors=%d/%d drove_toolbar=%d "
|
||||||
"(pane switch / header bar button; `drove_toolbar` is the pane's own switch having "
|
"(pane switch / header bar button; `drove_toolbar` is the pane's own switch having "
|
||||||
"moved the button)\n",
|
"moved the button)\n",
|
||||||
@@ -5732,6 +6065,18 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
|||||||
app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_names,
|
app.dbg_switcher_pills, app.dbg_switcher_labels, app.dbg_switcher_names,
|
||||||
app.dbg_switcher_icon_w, app.dbg_switcher_icon_h, app.dbg_switcher_w,
|
app.dbg_switcher_icon_w, app.dbg_switcher_icon_h, app.dbg_switcher_w,
|
||||||
app.dbg_switcher_h);
|
app.dbg_switcher_h);
|
||||||
|
// The help affordance layer: what the contextual-help toggle left on the surfaces, and where the
|
||||||
|
// guided tour stood when the last capture ran. Both are captured on the tick (`capture_help_state`),
|
||||||
|
// so neither asks a destroyed widget anything.
|
||||||
|
std::printf("[udpscope] help layer: %s\n", app.dbg_context_help.c_str());
|
||||||
|
// GTK's own log at warning or above, counted for the whole run (see `count_gtk_log`). A probe
|
||||||
|
// cannot read one of these -- they are written by the toolkit, not stored in a widget -- so this
|
||||||
|
// count is the reading that catches the class, and the run must print `0`.
|
||||||
|
std::printf("[udpscope] gtk warnings: %d\n", g_gtk_warning_count);
|
||||||
|
// The bounds the help's annotated figures draw their crop boxes from: window coordinates, or
|
||||||
|
// -1,-1,0,0 for a widget that is not on screen (`is_visible()`, not `get_visible()` -- a widget
|
||||||
|
// under a hidden parent keeps its own flag true). Measured on the tick, after a frame.
|
||||||
|
std::printf("[udpscope] help figures: %s\n", app.dbg_help_figures.c_str());
|
||||||
std::printf("[udpscope] mode with trigger on: %s\n", app.dbg_mode_triggered.c_str());
|
std::printf("[udpscope] mode with trigger on: %s\n", app.dbg_mode_triggered.c_str());
|
||||||
std::printf("[udpscope] trigger marker x=%.3f, view x_max=%.3f; model trigger_time=%.3f "
|
std::printf("[udpscope] trigger marker x=%.3f, view x_max=%.3f; model trigger_time=%.3f "
|
||||||
"valid=%d\n",
|
"valid=%d\n",
|
||||||
@@ -5897,10 +6242,14 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
|||||||
app.dbg_badge_frames[3]);
|
app.dbg_badge_frames[3]);
|
||||||
std::printf("[udpscope] trigger panel sensitive before the probe toggled it: %d; "
|
std::printf("[udpscope] trigger panel sensitive before the probe toggled it: %d; "
|
||||||
"signal list items=%d selected=%d; column visible after sidebar toggle off=%d; "
|
"signal list items=%d selected=%d; column visible after sidebar toggle off=%d; "
|
||||||
"pane switches live while their panes are off=%d\n",
|
"pane switches live while their panes are off=%d; arm sensitive with the trigger off=%d; "
|
||||||
|
"compact arm with the trigger off: visible=%d; with the trigger on and the pane "
|
||||||
|
"locked: visible=%d sensitive=%d width=%d label=%s\n",
|
||||||
app.dbg_trigger_sensitive_default, app.dbg_trigger_items,
|
app.dbg_trigger_sensitive_default, app.dbg_trigger_items,
|
||||||
app.dbg_trigger_selected, app.dbg_side_column_after_hide,
|
app.dbg_trigger_selected, app.dbg_side_column_after_hide,
|
||||||
app.dbg_panel_switch_live);
|
app.dbg_panel_switch_live, app.dbg_arm_sensitive, app.dbg_summary_arm_off,
|
||||||
|
app.dbg_summary_arm_visible, app.dbg_summary_arm_sensitive, app.dbg_summary_arm_width,
|
||||||
|
app.dbg_summary_arm_label.c_str());
|
||||||
{
|
{
|
||||||
// The panel's *frame* read back: the row boxes the `.ui` holds, and that every control the code built
|
// The panel's *frame* read back: the row boxes the `.ui` holds, and that every control the code built
|
||||||
// ended up inside the panel rather than in a row that is not there. A control is created in code and
|
// ended up inside the panel rather than in a row that is not there. A control is created in code and
|
||||||
@@ -5914,8 +6263,10 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
// The *file's* panel (`trigger_body`), not the wrapper this app puts it in: the heading row and the
|
||||||
|
// locked summary are the code's arrangement, and what these counts read is what the `.ui` holds.
|
||||||
int boxes = 0;
|
int boxes = 0;
|
||||||
for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_panel); c != nullptr;
|
for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_body); c != nullptr;
|
||||||
c = gtk_widget_get_next_sibling(c)) {
|
c = gtk_widget_get_next_sibling(c)) {
|
||||||
if (GTK_IS_BOX(c)) ++boxes;
|
if (GTK_IS_BOX(c)) ++boxes;
|
||||||
}
|
}
|
||||||
@@ -5927,8 +6278,8 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
|||||||
// a throwaway program, all three ways of setting it). So the walk the search widgets are read with has
|
// a throwaway program, all three ways of setting it). So the walk the search widgets are read with has
|
||||||
// to start at the expander's child and not at the panel -- walking to the panel reads 0/4 on a panel
|
// to start at the expander's child and not at the panel -- walking to the panel reads 0/4 on a panel
|
||||||
// that is entirely correct, which is what this line did first.
|
// that is entirely correct, which is what this line did first.
|
||||||
GtkWidget* search_root = app.trigger_panel;
|
GtkWidget* search_root = app.trigger_body;
|
||||||
for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_panel); c != nullptr;
|
for (GtkWidget* c = gtk_widget_get_first_child(app.trigger_body); c != nullptr;
|
||||||
c = gtk_widget_get_next_sibling(c)) {
|
c = gtk_widget_get_next_sibling(c)) {
|
||||||
if (GTK_IS_EXPANDER(c)) {
|
if (GTK_IS_EXPANDER(c)) {
|
||||||
if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(c))) search_root = inner;
|
if (GtkWidget* inner = gtk_expander_get_child(GTK_EXPANDER(c))) search_root = inner;
|
||||||
@@ -5942,21 +6293,21 @@ int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisit
|
|||||||
"[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 "
|
"[udpscope] trigger panel: boxes=%d controls_inside=%d/15 search_widgets=%d/4 "
|
||||||
"search_inside=%d/4\n",
|
"search_inside=%d/4\n",
|
||||||
boxes,
|
boxes,
|
||||||
inside(app.trigger_signal_drop, app.trigger_panel) +
|
inside(app.trigger_signal_drop, app.trigger_body) +
|
||||||
inside(app.trigger_type_drop, app.trigger_panel) +
|
inside(app.trigger_type_drop, app.trigger_body) +
|
||||||
inside(app.trigger_edge_drop, app.trigger_panel) +
|
inside(app.trigger_edge_drop, app.trigger_body) +
|
||||||
inside(app.trigger_cmp_drop, app.trigger_panel) +
|
inside(app.trigger_cmp_drop, app.trigger_body) +
|
||||||
inside(app.trigger_mode_drop, app.trigger_panel) +
|
inside(app.trigger_mode_drop, app.trigger_body) +
|
||||||
inside(app.trigger_width_spin, app.trigger_panel) +
|
inside(app.trigger_width_spin, app.trigger_body) +
|
||||||
inside(app.trigger_width2_spin, app.trigger_panel) +
|
inside(app.trigger_width2_spin, app.trigger_body) +
|
||||||
inside(app.trigger_level2_spin, app.trigger_panel) +
|
inside(app.trigger_level2_spin, app.trigger_body) +
|
||||||
inside(app.trigger_timeout_spin, app.trigger_panel) +
|
inside(app.trigger_timeout_spin, app.trigger_body) +
|
||||||
inside(app.trigger_level_spin, app.trigger_panel) +
|
inside(app.trigger_level_spin, app.trigger_body) +
|
||||||
inside(app.trigger_pre_spin, app.trigger_panel) +
|
inside(app.trigger_pre_spin, app.trigger_body) +
|
||||||
inside(app.trigger_holdoff_spin, app.trigger_panel) +
|
inside(app.trigger_holdoff_spin, app.trigger_body) +
|
||||||
inside(app.trigger_band_spin, app.trigger_panel) +
|
inside(app.trigger_band_spin, app.trigger_body) +
|
||||||
inside(app.trigger_hf_spin, app.trigger_panel) +
|
inside(app.trigger_hf_spin, app.trigger_body) +
|
||||||
inside(app.events_record_switch, app.trigger_panel),
|
inside(app.events_record_switch, app.trigger_body),
|
||||||
search_present,
|
search_present,
|
||||||
inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
|
inside(app.search_run_button, search_root) + inside(app.search_cancel_button, search_root) +
|
||||||
inside(app.search_prev_button, search_root) + inside(app.search_next_button, search_root));
|
inside(app.search_prev_button, search_root) + inside(app.search_next_button, search_root));
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!-- SPDX-License-Identifier: MIT -->
|
|
||||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
|
||||||
<!--
|
|
||||||
The help window's chrome: a window, its own header bar, and a two-pane split whose sidebar is the
|
|
||||||
list of pages and whose content is one page at a time.
|
|
||||||
|
|
||||||
The split is AdwNavigationSplitView rather than a GtkBox with a separator in it, so the sidebar
|
|
||||||
collapses on a narrow window and comes back behind the header bar's button instead of squeezing the
|
|
||||||
text. On a wide one it is the same two panes it always was.
|
|
||||||
|
|
||||||
The page list itself is *not* here: its rows are the fourteen pages in `help_window.cpp`, each with
|
|
||||||
section headings between the groups, and a template cannot know them. What is here is the shape they
|
|
||||||
are put into: an id to find the list by, the scroller around the page, and the label that holds one
|
|
||||||
page's markup with the screenshot column above it.
|
|
||||||
-->
|
|
||||||
<interface>
|
|
||||||
<object class="AdwWindow" id="help_window">
|
|
||||||
<property name="title" translatable="yes">udpscope help</property>
|
|
||||||
<property name="default-width">860</property>
|
|
||||||
<property name="default-height">640</property>
|
|
||||||
<property name="content">
|
|
||||||
<object class="AdwToolbarView">
|
|
||||||
<child type="top">
|
|
||||||
<object class="AdwHeaderBar">
|
|
||||||
<property name="show-title">1</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<property name="content">
|
|
||||||
<object class="AdwNavigationSplitView">
|
|
||||||
<property name="sidebar">
|
|
||||||
<object class="AdwNavigationPage">
|
|
||||||
<property name="title" translatable="yes">Pages</property>
|
|
||||||
<property name="child">
|
|
||||||
<object class="GtkScrolledWindow">
|
|
||||||
<property name="hscrollbar-policy">never</property>
|
|
||||||
<property name="vscrollbar-policy">automatic</property>
|
|
||||||
<property name="width-request">220</property>
|
|
||||||
<property name="child">
|
|
||||||
<object class="GtkListBox" id="page_list">
|
|
||||||
<property name="selection-mode">single</property>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
<property name="content">
|
|
||||||
<object class="AdwNavigationPage">
|
|
||||||
<property name="title" translatable="yes">Page</property>
|
|
||||||
<property name="child">
|
|
||||||
<object class="GtkScrolledWindow">
|
|
||||||
<property name="hscrollbar-policy">never</property>
|
|
||||||
<property name="vscrollbar-policy">automatic</property>
|
|
||||||
<property name="hexpand">1</property>
|
|
||||||
<property name="child">
|
|
||||||
<object class="GtkBox" id="page_box">
|
|
||||||
<property name="orientation">vertical</property>
|
|
||||||
<child>
|
|
||||||
<object class="GtkBox" id="shot_box">
|
|
||||||
<property name="orientation">vertical</property>
|
|
||||||
<property name="spacing">8</property>
|
|
||||||
<property name="margin-start">18</property>
|
|
||||||
<property name="margin-end">18</property>
|
|
||||||
<property name="margin-top">14</property>
|
|
||||||
<property name="visible">0</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child>
|
|
||||||
<object class="GtkLabel" id="body">
|
|
||||||
<property name="use-markup">1</property>
|
|
||||||
<property name="wrap">1</property>
|
|
||||||
<property name="xalign">0</property>
|
|
||||||
<property name="yalign">0</property>
|
|
||||||
<property name="selectable">1</property>
|
|
||||||
<property name="margin-start">18</property>
|
|
||||||
<property name="margin-end">18</property>
|
|
||||||
<property name="margin-top">14</property>
|
|
||||||
<property name="margin-bottom">14</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
|
||||||
</property>
|
|
||||||
</object>
|
|
||||||
</interface>
|
|
||||||
+15
-1
@@ -121,7 +121,7 @@
|
|||||||
<object class="GtkShortcutsShortcut">
|
<object class="GtkShortcutsShortcut">
|
||||||
<property name="visible">1</property>
|
<property name="visible">1</property>
|
||||||
<property name="accelerator">F1</property>
|
<property name="accelerator">F1</property>
|
||||||
<property name="title" translatable="yes">Open the help window</property>
|
<property name="title" translatable="yes">Open the documentation</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
@@ -131,6 +131,20 @@
|
|||||||
<property name="title" translatable="yes">Open this shortcut overview</property>
|
<property name="title" translatable="yes">Open this shortcut overview</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator"><Control>t</property>
|
||||||
|
<property name="title" translatable="yes">Start the guided tour</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkShortcutsShortcut">
|
||||||
|
<property name="visible">1</property>
|
||||||
|
<property name="accelerator"><Control>h</property>
|
||||||
|
<property name="title" translatable="yes">Turn the contextual help on or off</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkShortcutsShortcut">
|
<object class="GtkShortcutsShortcut">
|
||||||
<property name="visible">1</property>
|
<property name="visible">1</property>
|
||||||
|
|||||||
+35
-7
@@ -21,16 +21,44 @@
|
|||||||
<property name="margin-start">8</property>
|
<property name="margin-start">8</property>
|
||||||
<property name="margin-end">8</property>
|
<property name="margin-end">8</property>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkLabel" id="signals_heading">
|
<!-- The heading row, with the button that switches this pane between its two modes: locked, where
|
||||||
<property name="label" translatable="yes">Signals</property>
|
the list shows the signals that are on and nothing else, and configuration, which is the list
|
||||||
<property name="halign">start</property>
|
with every control on it. It is the code that swaps the icon and the tooltip, because the mode
|
||||||
|
is the code's. -->
|
||||||
|
<object class="GtkBox" id="signals_head">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">8</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="signals_heading">
|
||||||
|
<property name="label" translatable="yes">Signals</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkButton" id="signals_edit">
|
||||||
|
<property name="icon-name">document-edit-symbolic</property>
|
||||||
|
<property name="valign">center</property>
|
||||||
|
<property name="css-classes">flat</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkLabel" id="signals_hint">
|
<!-- The hint line, wrapped in a box of its own so the contextual help's "(i)" can join it: a
|
||||||
<property name="label" translatable="yes">drag a signal onto a plot</property>
|
`GtkLabel` cannot host a sibling, and a hint overlaid in a corner could cover the text it
|
||||||
<property name="halign">start</property>
|
is meant to sit beside. The label keeps the slack, so the mark sits at the row's right. -->
|
||||||
<property name="css-classes">dim-label</property>
|
<object class="GtkBox" id="signals_note">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="signals_hint">
|
||||||
|
<property name="label" translatable="yes">drag a signal onto a plot</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
<property name="css-classes">dim-label</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
|
|||||||
@@ -33,6 +33,17 @@
|
|||||||
<property name="tooltip-text" translatable="yes">the event cache, relative time and the interface's theme — the same dialog as the main window's menu, and the only place relative time can still change this run</property>
|
<property name="tooltip-text" translatable="yes">the event cache, relative time and the interface's theme — the same dialog as the main window's menu, and the only place relative time can still change this run</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
|
<!-- The help menu: the same three affordances the window's Help view carries, because a
|
||||||
|
user who has not started yet is exactly the one who does not know how to start. Only
|
||||||
|
the button is here — its rows carry a handler and a setting, so they are code, the
|
||||||
|
same division every other row in this file follows. Beside `_Preferences`, because
|
||||||
|
both act on the *program* rather than on a source. -->
|
||||||
|
<child type="start">
|
||||||
|
<object class="GtkMenuButton" id="help_menu_button">
|
||||||
|
<property name="icon-name">help-about-symbolic</property>
|
||||||
|
<property name="tooltip-text" translatable="yes">the manual, a guided tour after start-up, and the contextual help</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
<child type="end">
|
<child type="end">
|
||||||
<object class="GtkButton" id="cancel_button">
|
<object class="GtkButton" id="cancel_button">
|
||||||
<property name="label" translatable="yes">_Cancel</property>
|
<property name="label" translatable="yes">_Cancel</property>
|
||||||
@@ -55,13 +66,41 @@
|
|||||||
<property name="title" translatable="yes">Sources</property>
|
<property name="title" translatable="yes">Sources</property>
|
||||||
<property name="icon-name">network-transmit-receive-symbolic</property>
|
<property name="icon-name">network-transmit-receive-symbolic</property>
|
||||||
<property name="child">
|
<property name="child">
|
||||||
<object class="GtkScrolledWindow">
|
<object class="GtkBox">
|
||||||
<property name="hscrollbar-policy">never</property>
|
<property name="orientation">vertical</property>
|
||||||
<property name="child">
|
<child>
|
||||||
<object class="GtkListBox" id="sources_list">
|
<!-- The heading row above the list. It exists mainly so the contextual help's
|
||||||
<property name="activate-on-single-click">1</property>
|
"(i)" has a row of its own to join: a hint appended to the list would be a
|
||||||
|
row among the sources, and one overlaid on the scroller could cover a row
|
||||||
|
it is meant to explain. The name is dim, because the switcher's pill above
|
||||||
|
already says which page this is. -->
|
||||||
|
<object class="GtkBox" id="sources_head">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<property name="margin-start">12</property>
|
||||||
|
<property name="margin-end">6</property>
|
||||||
|
<property name="margin-top">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel" id="sources_heading">
|
||||||
|
<property name="label" translatable="yes">Saved sources</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
<property name="hexpand">1</property>
|
||||||
|
<property name="css-classes">dim-label</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
</object>
|
</object>
|
||||||
</property>
|
</child>
|
||||||
|
<child>
|
||||||
|
<object class="GtkScrolledWindow">
|
||||||
|
<property name="vexpand">1</property>
|
||||||
|
<property name="hscrollbar-policy">never</property>
|
||||||
|
<property name="child">
|
||||||
|
<object class="GtkListBox" id="sources_list">
|
||||||
|
<property name="activate-on-single-click">1</property>
|
||||||
|
</object>
|
||||||
|
</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
</object>
|
</object>
|
||||||
</property>
|
</property>
|
||||||
</object>
|
</object>
|
||||||
@@ -86,7 +125,7 @@
|
|||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkBox">
|
<object class="GtkBox" id="waves_actions">
|
||||||
<property name="orientation">horizontal</property>
|
<property name="orientation">horizontal</property>
|
||||||
<property name="spacing">6</property>
|
<property name="spacing">6</property>
|
||||||
<property name="halign">end</property>
|
<property name="halign">end</property>
|
||||||
|
|||||||
+16
-7
@@ -212,16 +212,25 @@
|
|||||||
<property name="label" translatable="yes">Arm</property>
|
<property name="label" translatable="yes">Arm</property>
|
||||||
</object>
|
</object>
|
||||||
</child>
|
</child>
|
||||||
<child>
|
|
||||||
<object class="GtkButton" id="trigger_live">
|
|
||||||
<property name="label" translatable="yes">Go live (trigger off)</property>
|
|
||||||
<property name="tooltip-text" translatable="yes">disable trigger mode and stream continuously</property>
|
|
||||||
</object>
|
|
||||||
</child>
|
|
||||||
<child>
|
<child>
|
||||||
<object class="GtkExpander" id="search_exp">
|
<object class="GtkExpander" id="search_exp">
|
||||||
<property name="label" translatable="yes">Search the buffer</property>
|
|
||||||
<property name="tooltip-text" translatable="yes">find every place in the retained buffer where the condition above holds, and step through them</property>
|
<property name="tooltip-text" translatable="yes">find every place in the retained buffer where the condition above holds, and step through them</property>
|
||||||
|
<!-- The header is a box, not a plain label, so the contextual help's "(i)" has a row to join
|
||||||
|
as its last child and sits beside the text while the block is collapsed. The block's own
|
||||||
|
*child* is not in the widget-parent chain until the expander is expanded (see
|
||||||
|
docs/claude/invariants-gtk-and-chrome.md), so a hint hung there would be off screen. -->
|
||||||
|
<child type="label">
|
||||||
|
<object class="GtkBox" id="search_head">
|
||||||
|
<property name="orientation">horizontal</property>
|
||||||
|
<property name="spacing">6</property>
|
||||||
|
<child>
|
||||||
|
<object class="GtkLabel">
|
||||||
|
<property name="label" translatable="yes">Search the buffer</property>
|
||||||
|
<property name="halign">start</property>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
|
</object>
|
||||||
|
</child>
|
||||||
<property name="child">
|
<property name="child">
|
||||||
|
|
||||||
<object class="GtkBox" id="search_sbox">
|
<object class="GtkBox" id="search_sbox">
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
<!-- SPDX-License-Identifier: MIT -->
|
<!-- SPDX-License-Identifier: MIT -->
|
||||||
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
|
||||||
<!--
|
<!--
|
||||||
The application's UI files, compiled into the binary beside the icons and the help pages, so there
|
The application's UI files, compiled into the binary beside the icons, so there is still exactly
|
||||||
is still exactly one file to run. `preprocess="xml-stripblanks"` is what catches a malformed file:
|
one file to run. (The manual is no longer a bundle here: it is a Mallard set installed for Yelp.)
|
||||||
|
`preprocess="xml-stripblanks"` is what catches a malformed file:
|
||||||
gtk_builder_new_from_resource() on a broken .ui returns nothing usable and the window simply does
|
gtk_builder_new_from_resource() on a broken .ui returns nothing usable and the window simply does
|
||||||
not open, which is a silent failure at runtime and a loud one here.
|
not open, which is a silent failure at runtime and a loud one here.
|
||||||
-->
|
-->
|
||||||
@@ -11,7 +12,6 @@
|
|||||||
<gresource prefix="/org/udpscope/ui">
|
<gresource prefix="/org/udpscope/ui">
|
||||||
<file preprocess="xml-stripblanks">shortcuts.ui</file>
|
<file preprocess="xml-stripblanks">shortcuts.ui</file>
|
||||||
<file preprocess="xml-stripblanks">preferences.ui</file>
|
<file preprocess="xml-stripblanks">preferences.ui</file>
|
||||||
<file preprocess="xml-stripblanks">help.ui</file>
|
|
||||||
<file preprocess="xml-stripblanks">source-form.ui</file>
|
<file preprocess="xml-stripblanks">source-form.ui</file>
|
||||||
<file preprocess="xml-stripblanks">source-chooser.ui</file>
|
<file preprocess="xml-stripblanks">source-chooser.ui</file>
|
||||||
<file preprocess="xml-stripblanks">loading.ui</file>
|
<file preprocess="xml-stripblanks">loading.ui</file>
|
||||||
|
|||||||
+308
-2
@@ -57,6 +57,57 @@ enum class EventGroupKind {
|
|||||||
Compare, ///< two events in two plots side by side, at one scale, with synchronised axes
|
Compare, ///< two events in two plots side by side, at one scale, with synchronised axes
|
||||||
Accumulate, ///< up to four events overlaid in the same traces, translucent, so agreement is brightness
|
Accumulate, ///< up to four events overlaid in the same traces, translucent, so agreement is brightness
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- the guided tour, shared by both windows (ui_tour.cpp) -------------------
|
||||||
|
//
|
||||||
|
// The tour is *two* tours now -- the main window's and the source chooser's -- because the chooser is
|
||||||
|
// the window a first run meets first and the surfaces it must explain are its own. The machinery is
|
||||||
|
// one implementation: a `TourView` is the overlay children (the four dim rectangles, the spotlight
|
||||||
|
// outline and the card) plus where the walk stands in it, and either window holds one. What differs
|
||||||
|
// is only the *stops*: a stop's target is a widget the owner resolves, so each window keeps its own
|
||||||
|
// table (the chooser runs before `App` exists and could not name one of `App`'s fields).
|
||||||
|
|
||||||
|
/// One stop of a guided tour: the widget to spotlight, the page it stands on, and the two paragraphs
|
||||||
|
/// of text beside it. The target is resolved when the tour starts, so the table may name widgets that
|
||||||
|
/// do not exist yet.
|
||||||
|
struct TourStop {
|
||||||
|
GtkWidget* target;
|
||||||
|
/// The named page of the view's `page_stack` the target lives on, or nullptr when the stop needs no
|
||||||
|
/// page. The tour selects it as the stop is entered, so a target on a page the user has left is
|
||||||
|
/// made visible instead of being walked over or spotlighted where it is not.
|
||||||
|
const char* page;
|
||||||
|
const char* title;
|
||||||
|
const char* body;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A tour's widgets and its place in the walk. `hole` is the spotlight's rectangle in the overlay's
|
||||||
|
/// own coordinates, kept from the last frame that could measure the target: a frame can come between
|
||||||
|
/// two layout passes, where `gtk_widget_compute_bounds()` answers nothing, and a hole that emptied
|
||||||
|
/// there would make the spotlight and the card jump.
|
||||||
|
struct TourView {
|
||||||
|
GtkWidget* overlay = nullptr;
|
||||||
|
GtkWidget* dim[4] = {};
|
||||||
|
GtkWidget* spot = nullptr;
|
||||||
|
GtkWidget* card = nullptr;
|
||||||
|
GtkWidget* title = nullptr;
|
||||||
|
GtkWidget* body = nullptr;
|
||||||
|
GtkWidget* counter = nullptr;
|
||||||
|
GtkWidget* back = nullptr;
|
||||||
|
GtkWidget* next = nullptr;
|
||||||
|
int hole[4] = {0, 0, 0, 0};
|
||||||
|
bool running = false;
|
||||||
|
int index = 0;
|
||||||
|
std::vector<TourStop> plan;
|
||||||
|
/// The stack whose pages the stops' `page` names select. A window has exactly one such surface (the
|
||||||
|
/// sidebar; the chooser's two pages), so it is the view's and not each stop's. Set where the walk
|
||||||
|
/// begins, when the widgets exist; null leaves every stop on the page it finds.
|
||||||
|
GtkWidget* page_stack = nullptr;
|
||||||
|
/// Runs when the walk passes its last stop (Next/Finish, never Skip): the chooser's hands the tour
|
||||||
|
/// on to the application through `Config::tour_pending`.
|
||||||
|
void (*on_done)(void* user) = nullptr;
|
||||||
|
void* done_user = nullptr;
|
||||||
|
};
|
||||||
|
|
||||||
struct App {
|
struct App {
|
||||||
ScopeModel* model = nullptr;
|
ScopeModel* model = nullptr;
|
||||||
Acquisition* acq = nullptr;
|
Acquisition* acq = nullptr;
|
||||||
@@ -195,11 +246,90 @@ struct App {
|
|||||||
GtkWidget* search_marks_label = nullptr;
|
GtkWidget* search_marks_label = nullptr;
|
||||||
GtkWidget* search_prev_button = nullptr;
|
GtkWidget* search_prev_button = nullptr;
|
||||||
GtkWidget* search_next_button = nullptr;
|
GtkWidget* search_next_button = nullptr;
|
||||||
|
/// The expander the block lives in, so the probe can read its collapsed state when it reads the
|
||||||
|
/// header hint.
|
||||||
|
GtkWidget* search_exp = nullptr;
|
||||||
/// Set true when the last `search_jump()` produced an empty cut — the hit was so close to the
|
/// Set true when the last `search_jump()` produced an empty cut — the hit was so close to the
|
||||||
/// oldest retained sample that `publish_pause_view()` held nothing. Cleared at every jump.
|
/// oldest retained sample that `publish_pause_view()` held nothing. Cleared at every jump.
|
||||||
bool search_jump_empty = false;
|
bool search_jump_empty = false;
|
||||||
GtkWidget* menu_button = nullptr;
|
GtkWidget* menu_button = nullptr;
|
||||||
GtkWidget* cursors_toggle = nullptr;
|
GtkWidget* cursors_toggle = nullptr;
|
||||||
|
/// --- the help affordance layer -------------------------------------------
|
||||||
|
///
|
||||||
|
/// The overflow menu's two views. The popover's child is a `GtkStack` holding both, and the
|
||||||
|
/// *visible child* is the view: swapping the stack rather than rebuilding the box is what keeps
|
||||||
|
/// `layout_toggle` — which points into the main view and is read by the tick — a live widget.
|
||||||
|
GtkWidget* menu_stack = nullptr;
|
||||||
|
GtkWidget* menu_main_view = nullptr;
|
||||||
|
GtkWidget* menu_help_view = nullptr;
|
||||||
|
/// The Help view's own rows, so the probe can read them and drive them without walking the popover:
|
||||||
|
/// the two existing dialogs' entry points and the two new actions.
|
||||||
|
GtkWidget* menu_shortcuts_row = nullptr;
|
||||||
|
GtkWidget* menu_docs_row = nullptr;
|
||||||
|
GtkWidget* menu_tour_row = nullptr;
|
||||||
|
/// The two navigation rows: the main view's door into the Help view, and the Back row out of it.
|
||||||
|
GtkWidget* menu_help_row = nullptr;
|
||||||
|
GtkWidget* menu_back_row = nullptr;
|
||||||
|
/// "Enable contextual help": a stateful menu row whose check *is* the setting.
|
||||||
|
GtkWidget* contextual_toggle = nullptr;
|
||||||
|
/// The "?" each surface carries, one per row of the table in `ui_context_help.cpp`. These are the
|
||||||
|
/// heading rows the buttons join — not the surfaces themselves — so a hint is a sibling of a
|
||||||
|
/// title rather than an overlay on top of a control. `math_head` only exists while the math
|
||||||
|
/// editor is open, which is why the row is skipped when it is null.
|
||||||
|
GtkWidget* trigger_head = nullptr;
|
||||||
|
GtkWidget* cursors_head = nullptr;
|
||||||
|
GtkWidget* measures_head = nullptr;
|
||||||
|
GtkWidget* signals_head = nullptr;
|
||||||
|
GtkWidget* events_head = nullptr;
|
||||||
|
GtkWidget* math_head = nullptr;
|
||||||
|
/// The status line's row, and the header bar's slot for the window control: the two surfaces that
|
||||||
|
/// are not a panel with a heading.
|
||||||
|
GtkWidget* status_row = nullptr;
|
||||||
|
GtkWidget* window_control_slot = nullptr;
|
||||||
|
/// The hint hosts that are *not* a pane's heading row: a specific control's own row, so the "?" sits
|
||||||
|
/// beside the thing it explains -- the "record events" switch, the pre-trigger share row, and the
|
||||||
|
/// signals pane's hint line (whose box exists so a hint can join it beside the text).
|
||||||
|
GtkWidget* trigger_record_row = nullptr;
|
||||||
|
GtkWidget* trigger_pre_row = nullptr;
|
||||||
|
/// The search block's expander *header* -- the box the contextual help's "trigger_search" hint joins
|
||||||
|
/// as its last child. The header, not the block: the expander's child is out of the widget-parent
|
||||||
|
/// chain until the block is expanded, so a hint there is invisible while it is collapsed.
|
||||||
|
GtkWidget* trigger_search = nullptr;
|
||||||
|
GtkWidget* signals_note = nullptr;
|
||||||
|
/// The guided tour. `tour.overlay` is a `GtkOverlay` inserted above the window's content, and the
|
||||||
|
/// four dim rectangles, the spotlight and the card are its overlay children; `tour.plan` is the
|
||||||
|
/// steps whose target was on screen when the tour started, so a surface that is not there is not
|
||||||
|
/// walked over. The machinery is shared with the source chooser's tour -- see `TourView`.
|
||||||
|
TourView tour;
|
||||||
|
/// The first-run auto-start: set by the timer that fires it, and read by the probe and the dump.
|
||||||
|
/// The decision itself is `settings.tour_seen`, which is the whole of "this has happened before".
|
||||||
|
bool tour_auto_started = false;
|
||||||
|
/// The chooser's own "Guided tour" request (`Config::tour_pending`), recorded when its timer runs.
|
||||||
|
/// A separate flag from the auto-start because it is a *different* thing: the user asked for the
|
||||||
|
/// tour before the window existed, rather than never having seen it.
|
||||||
|
bool tour_requested = false;
|
||||||
|
/// The self-check's reading of the manual's launch (`UDPSCOPE_PROBE_HELP_MS`). The launch is
|
||||||
|
/// asynchronous and the viewer is another process, so the outcome arrives in a callback and the
|
||||||
|
/// probe prints it from a timer afterwards; `attempted` is set at the call, because a launch that
|
||||||
|
/// never called back is itself a reading.
|
||||||
|
bool dbg_help_launch_attempted = false;
|
||||||
|
bool dbg_help_launch_done = false;
|
||||||
|
bool dbg_help_launch_ok = false;
|
||||||
|
std::string dbg_help_launch_error;
|
||||||
|
/// The help layer's own dump readings, captured on the tick while the window is alive (nothing
|
||||||
|
/// here may be read in the dump -- see `docs/claude/verification.md`).
|
||||||
|
std::string dbg_context_help;
|
||||||
|
std::string dbg_help_figures;
|
||||||
|
int dbg_tour_steps = -1;
|
||||||
|
int dbg_tour_step = -1;
|
||||||
|
int dbg_tour_visible = -1;
|
||||||
|
/// Which step of the help-affordance probe runs next (`UDPSCOPE_PROBE_HELPUI_MS`), and how many
|
||||||
|
/// event controllers the window carried before the tour opened -- the reading that says the tour
|
||||||
|
/// left nothing behind.
|
||||||
|
int dbg_helpui_step = 0;
|
||||||
|
int dbg_helpui_controllers = -1;
|
||||||
|
/// `UDPSCOPE_PROBE_TOUR_SURFACE_MS`: which step of that probe runs next.
|
||||||
|
int dbg_tour_surface_step = 0;
|
||||||
/// Where the panel controls ended up, decided once at build time: the dump runs after the window
|
/// Where the panel controls ended up, decided once at build time: the dump runs after the window
|
||||||
/// is destroyed and cannot ask GTK anything about a widget.
|
/// is destroyed and cannot ask GTK anything about a widget.
|
||||||
std::string dbg_panel_controls;
|
std::string dbg_panel_controls;
|
||||||
@@ -239,6 +369,9 @@ struct App {
|
|||||||
/// parent cannot be clicked, so a switch that went insensitive with its own pane could never turn
|
/// parent cannot be clicked, so a switch that went insensitive with its own pane could never turn
|
||||||
/// it back on.
|
/// it back on.
|
||||||
int dbg_panel_switch_live = -1;
|
int dbg_panel_switch_live = -1;
|
||||||
|
/// The Arm button, read with the trigger off: it is the pane's one runtime control, so it is the one
|
||||||
|
/// thing in the body that *is* disabled with the mode.
|
||||||
|
int dbg_arm_sensitive = -1;
|
||||||
int dbg_switcher_policy = -1;
|
int dbg_switcher_policy = -1;
|
||||||
/// Whether `name_side_tabs()` has named the pills: it can only run once the switcher is mapped.
|
/// Whether `name_side_tabs()` has named the pills: it can only run once the switcher is mapped.
|
||||||
bool side_tabs_named = false;
|
bool side_tabs_named = false;
|
||||||
@@ -258,6 +391,51 @@ struct App {
|
|||||||
/// True while `sync_panel_switches()` is writing the switches: setting a `GtkSwitch` emits its own
|
/// True while `sync_panel_switches()` is writing the switches: setting a `GtkSwitch` emits its own
|
||||||
/// `notify::active`, and without this the program's own write would come back as a click.
|
/// `notify::active`, and without this the program's own write would come back as a click.
|
||||||
bool panel_switch_sync = false;
|
bool panel_switch_sync = false;
|
||||||
|
/// True while a drag has the overlaid sidebar hidden: what it was showing before, so `drag-end` puts it
|
||||||
|
/// back rather than guessing.
|
||||||
|
bool sidebar_hidden_for_drag = false;
|
||||||
|
/// The channel a session's trigger names, until `rebuild_trigger_signals()` can resolve it: the
|
||||||
|
/// file stores a *name* and the trigger needs an index into the acquired channels, which do not
|
||||||
|
/// exist until the stream has been described. Empty means "nothing pending".
|
||||||
|
std::string pending_trigger_channel;
|
||||||
|
/// The two panes' modes. *Locked* is the one a returning user gets: the trigger pane shows a line
|
||||||
|
/// that says what it is set to, the signal list shows only the signals that are on, and neither
|
||||||
|
/// offers a control that changes the configuration. A first run -- no session file for this source
|
||||||
|
/// -- starts in configuration mode, because that is what a first run is for. The flags last for the
|
||||||
|
/// session: a lock is a mode, not a setting, so it is not persisted.
|
||||||
|
bool trigger_locked = false;
|
||||||
|
bool signals_locked = false;
|
||||||
|
/// The locked trigger pane's content: a small definition list, because one line cannot hold the
|
||||||
|
/// signal, the condition, the level and the mode, and those are what a user reads the pane for.
|
||||||
|
GtkWidget* trigger_summary = nullptr;
|
||||||
|
static constexpr int kTriggerSummaryRows = 8;
|
||||||
|
GtkWidget* trigger_summary_keys[kTriggerSummaryRows] = {};
|
||||||
|
GtkWidget* trigger_summary_values[kTriggerSummaryRows] = {};
|
||||||
|
/// The compact line's arm control. It lives in the pane's *heading*, the part that stays on screen when
|
||||||
|
/// the body is hidden, and `sync_arm_buttons()` states it: present only while trigger mode is on and the
|
||||||
|
/// pane is locked, which is the one combination where the body's own Arm button has gone with the body.
|
||||||
|
GtkWidget* trigger_summary_arm = nullptr;
|
||||||
|
GtkWidget* trigger_edit = nullptr;
|
||||||
|
GtkWidget* signals_edit = nullptr;
|
||||||
|
int dbg_trigger_locked = -1;
|
||||||
|
int dbg_summary_arm_off = -1; ///< the compact line's arm control with the trigger *off*
|
||||||
|
int dbg_summary_arm_visible = -1; ///< the compact line's arm control, locked and with the trigger on
|
||||||
|
int dbg_summary_arm_sensitive = -1;
|
||||||
|
std::string dbg_summary_arm_label; ///< its text, which is the armed state in the user's words
|
||||||
|
int dbg_summary_arm_width = -1; ///< its allocation a frame later: 0 means "not laid out"
|
||||||
|
int dbg_signals_locked = -1;
|
||||||
|
int dbg_trigger_body_visible = -1;
|
||||||
|
/// The compact line the locked trigger pane shows. It is the pane's whole content in that mode, so it
|
||||||
|
/// is read rather than assumed: empty here is a locked pane showing nothing at all.
|
||||||
|
std::string dbg_trigger_summary;
|
||||||
|
/// The same line as the exit probe found it, captured before the probe touches the trigger. The locked
|
||||||
|
/// pane says one thing about a trigger that is running and another about one that is not, and only a run
|
||||||
|
/// whose session has it off shows the second -- so the dump carries both rather than assuming either.
|
||||||
|
std::string dbg_trigger_summary_early;
|
||||||
|
/// The two [edit] buttons' icons, read in each mode by clicking into it and back: a button whose icon
|
||||||
|
/// does not resolve is *blank*, and that is how the missing "done" icon was found.
|
||||||
|
std::string dbg_pane_edit_icon;
|
||||||
|
std::string dbg_pane_view_icon;
|
||||||
/// The cursors pane: listing what the instruments read -- T0/T1/dT always, per row an Y1/Y2/dY
|
/// The cursors pane: listing what the instruments read -- T0/T1/dT always, per row an Y1/Y2/dY
|
||||||
/// where rulers are up, and in `mix` mode the value each cursor crosses each trace at.
|
/// where rulers are up, and in `mix` mode the value each cursor crosses each trace at.
|
||||||
GtkWidget* cursors_mode_drop = nullptr;
|
GtkWidget* cursors_mode_drop = nullptr;
|
||||||
@@ -328,6 +506,10 @@ struct App {
|
|||||||
std::vector<MeasureLevel> level; ///< one accumulator per entry of `pct`
|
std::vector<MeasureLevel> level; ///< one accumulator per entry of `pct`
|
||||||
MeasureFilter scan_filter; ///< pass one's state
|
MeasureFilter scan_filter; ///< pass one's state
|
||||||
MeasureFilter acc_filter; ///< pass two's; a separate walk, so a separate state
|
MeasureFilter acc_filter; ///< pass two's; a separate walk, so a separate state
|
||||||
|
/// Some measurement on this window reads a flat level, so pass one folds the bucket
|
||||||
|
/// histogram the flat-level types are answered from. Only then: a pane of means and extremes
|
||||||
|
/// should not pay for a density estimate it never reads.
|
||||||
|
bool flat_wanted = false;
|
||||||
bool ok = false;
|
bool ok = false;
|
||||||
};
|
};
|
||||||
std::vector<MeasureWindow> measure_samples;
|
std::vector<MeasureWindow> measure_samples;
|
||||||
@@ -784,6 +966,11 @@ struct App {
|
|||||||
/// of them are streamed signals. The `MATH` divider and the math channels account for the rest, and
|
/// of them are streamed signals. The `MATH` divider and the math channels account for the rest, and
|
||||||
/// `rows == signals + 1 + math` is the invariant the dump checks -- a list that lost its `+`.
|
/// `rows == signals + 1 + math` is the invariant the dump checks -- a list that lost its `+`.
|
||||||
int dbg_signal_rows = 0;
|
int dbg_signal_rows = 0;
|
||||||
|
/// How many rows of the list carry a `GtkDragSource`: `rows - 1`, the divider not being draggable.
|
||||||
|
int dbg_signal_drag_sources = -1;
|
||||||
|
/// The drag-with-an-overlaid-sidebar pair: the sidebar hid for the drag, and came back after it.
|
||||||
|
int dbg_drag_hid_sidebar = -1;
|
||||||
|
int dbg_drag_restored_sidebar = -1;
|
||||||
std::size_t dbg_signal_plain = 0;
|
std::size_t dbg_signal_plain = 0;
|
||||||
int dbg_side_column_after_hide = 1;
|
int dbg_side_column_after_hide = 1;
|
||||||
std::string dbg_mode_triggered;
|
std::string dbg_mode_triggered;
|
||||||
@@ -1279,7 +1466,7 @@ void on_signal_color_chosen(GObject* source, GAsyncResult* res, gpointer user);
|
|||||||
void open_signal_color(App& app, std::size_t signal);
|
void open_signal_color(App& app, std::size_t signal);
|
||||||
void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals);
|
void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals);
|
||||||
/// The `MATH` divider row in the one channel list, and the `+` it carries.
|
/// The `MATH` divider row in the one channel list, and the `+` it carries.
|
||||||
GtkWidget* make_math_divider(App& app);
|
GtkWidget* make_math_divider(App& app, bool with_add);
|
||||||
void on_math_add_clicked(GtkButton* button, gpointer user);
|
void on_math_add_clicked(GtkButton* button, gpointer user);
|
||||||
/// The index in `App::math` of the channel a synthetic signal is named after, or `-1`.
|
/// The index in `App::math` of the channel a synthetic signal is named after, or `-1`.
|
||||||
int math_index_for_signal(App& app, const std::string& name);
|
int math_index_for_signal(App& app, const std::string& name);
|
||||||
@@ -1314,7 +1501,6 @@ void on_layout_drag_begin(GtkGestureDrag* gesture, double x, double y, gpointer
|
|||||||
void on_layout_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y, gpointer user);
|
void on_layout_drag_update(GtkGestureDrag* gesture, double offset_x, double offset_y, gpointer user);
|
||||||
void on_layout_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y, gpointer user);
|
void on_layout_drag_end(GtkGestureDrag* gesture, double offset_x, double offset_y, gpointer user);
|
||||||
void on_trigger_toggled(GtkToggleButton* button, gpointer user);
|
void on_trigger_toggled(GtkToggleButton* button, gpointer user);
|
||||||
void on_go_live(GtkButton*, gpointer user);
|
|
||||||
plot::u32 measure_color(std::size_t index);
|
plot::u32 measure_color(std::size_t index);
|
||||||
bool visible_samples(App& app, std::size_t channel, std::vector<double>& time, std::vector<double>& value);
|
bool visible_samples(App& app, std::size_t channel, std::vector<double>& time, std::vector<double>& value);
|
||||||
int measure_channel(App& app, const std::string& signal);
|
int measure_channel(App& app, const std::string& signal);
|
||||||
@@ -1413,6 +1599,37 @@ void select_side_tab(App& app, const char* name);
|
|||||||
/// Reads each pane's own switch back off the header bar's button, and the other way round: the two are
|
/// Reads each pane's own switch back off the header bar's button, and the other way round: the two are
|
||||||
/// one control in two places, and `panel_switch_sync` keeps the write from looking like a click.
|
/// one control in two places, and `panel_switch_sync` keeps the write from looking like a click.
|
||||||
void sync_panel_switches(App& app);
|
void sync_panel_switches(App& app);
|
||||||
|
/// The trigger's four enumerations as words: the panel's dropdown labels and the session file's
|
||||||
|
/// vocabulary, in one place so the two cannot drift.
|
||||||
|
const char* trigger_type_text(TriggerType t);
|
||||||
|
TriggerType trigger_type_from_text(const std::string& s);
|
||||||
|
const char* trigger_edge_text(TriggerEdge e);
|
||||||
|
TriggerEdge trigger_edge_from_text(const std::string& s);
|
||||||
|
const char* width_cmp_text(WidthCmp c);
|
||||||
|
WidthCmp width_cmp_from_text(const std::string& s);
|
||||||
|
const char* trigger_mode_text(TriggerMode m);
|
||||||
|
TriggerMode trigger_mode_from_text(const std::string& s);
|
||||||
|
/// The panel's controls from the model, and the session's trigger into both.
|
||||||
|
void write_trigger_widgets(App& app);
|
||||||
|
/// Hides the sidebar for the length of a drag when it is *overlaid*, and puts it back after. With the
|
||||||
|
/// split view collapsed the sidebar covers the plot, so a drag that starts in the signal list has nowhere
|
||||||
|
/// to land: the pointer is over the sidebar, not over a drop target.
|
||||||
|
void row_drag_sidebar(App& app, bool dragging);
|
||||||
|
/// The compact line the trigger pane shows while it is locked, and the lock itself: the panes'
|
||||||
|
/// bodies, their [edit] buttons and the signal list's rows all follow from `trigger_locked` and
|
||||||
|
/// `signals_locked`.
|
||||||
|
void sync_trigger_summary(App& app);
|
||||||
|
|
||||||
|
/// Both arm buttons -- the panel's and the compact line's -- from the one armed state the producer keeps in
|
||||||
|
/// `model->trigger_armed()`. The label was written from four places (the click, the toolbar toggle, the
|
||||||
|
/// curator's re-read, the start-up default) and a second button would have needed a fifth copy of the same
|
||||||
|
/// two lines; the drift that costs is a button reading "Arm" while the engine is armed. Sensitivity, and
|
||||||
|
/// the compact button's visibility, are stated here too and read from the toolbar toggle -- the control
|
||||||
|
/// that owns `enabled`, per the restore rules -- so no caller needs the model lock to state any of it.
|
||||||
|
void sync_arm_buttons(App& app);
|
||||||
|
void apply_pane_lock(App& app);
|
||||||
|
void on_pane_edit_clicked(GtkButton* button, gpointer user);
|
||||||
|
void apply_trigger_layout(App& app, const LayoutState::Trigger& t);
|
||||||
void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user);
|
void on_panel_switch_toggled(GObject* obj, GParamSpec*, gpointer user);
|
||||||
void update_side_visibility(App& app);
|
void update_side_visibility(App& app);
|
||||||
void on_sidebar_toggled(GtkToggleButton* button, gpointer user);
|
void on_sidebar_toggled(GtkToggleButton* button, gpointer user);
|
||||||
@@ -1548,6 +1765,95 @@ void collect_spins(GtkWidget* w, std::vector<double>* out);
|
|||||||
gboolean defaults_probe(gpointer user);
|
gboolean defaults_probe(gpointer user);
|
||||||
gboolean keys_probe(gpointer user);
|
gboolean keys_probe(gpointer user);
|
||||||
gboolean help_probe(gpointer user);
|
gboolean help_probe(gpointer user);
|
||||||
|
void helpui_probe_next(App& a, int step, guint delay_ms);
|
||||||
|
gboolean helpui_probe_step(gpointer user);
|
||||||
|
/// `UDPSCOPE_PROBE_TOUR_SURFACE_MS`: a stop that stands on another page of a stack, entered from the
|
||||||
|
/// page it is not on -- the sidebar put on Measure, the walk to the signals stop, the reading of the
|
||||||
|
/// page and of the spotlight, and then the same walk with the sidebar *collapsed*.
|
||||||
|
void tour_surface_probe_next(App& a, guint delay_ms);
|
||||||
|
|
||||||
|
// --- the help affordance layer (ui_context_help.cpp, ui_tour.cpp) ----------
|
||||||
|
//
|
||||||
|
// Two things live here and they are not the same thing: the *contextual help* is a "?" on every
|
||||||
|
// surface the app can explain, and the *guided tour* is one walk over five of them. Both read the
|
||||||
|
// one setting the Help menu's toggle writes.
|
||||||
|
|
||||||
|
/// Adds or removes the "?" affordances so they match `settings.contextual_help`, and does nothing
|
||||||
|
/// else. Idempotent, so it can be called after the window is built, on every change of the setting
|
||||||
|
/// and once more when a dialog that carries one of the hosts is opened.
|
||||||
|
void context_help_sync(App& app);
|
||||||
|
/// How many "?" affordances are on screen, writing their ids space-separated into `ids`. Found by
|
||||||
|
/// the data key rather than kept in a list, so a host that died with a dialog cannot leave a stale
|
||||||
|
/// pointer behind.
|
||||||
|
int context_help_count(App& app, std::string* ids);
|
||||||
|
/// The menu row's handler: writes the setting, saves the file and re-states the layer.
|
||||||
|
void on_contextual_help_toggled(GtkToggleButton* button, gpointer user);
|
||||||
|
/// The "?" for one surface by id, for a host that only exists while a dialog is open (the math
|
||||||
|
/// editor is rebuilt on every open, so it cannot be a row the window's own build visits).
|
||||||
|
void context_help_add(App& app, const char* id, GtkWidget* host);
|
||||||
|
/// The same affordance for a surface that is *not* in `App`'s table -- the source chooser's, which
|
||||||
|
/// runs before `App` exists and so cannot name one of its fields. `host` is a `GtkBox` and the button
|
||||||
|
/// joins it as its last child, which is the rule the table's own rows follow; `on` false takes any
|
||||||
|
/// hint off `host` instead. `shortcut` may be "".
|
||||||
|
void context_help_button(GtkWidget* host, const char* id, const char* title, const char* body,
|
||||||
|
const char* shortcut, bool on);
|
||||||
|
/// How many hints a widget subtree carries, and their ids, for a window that has no `App` (the
|
||||||
|
/// chooser's probe). `context_help_count()` is this with `App::window` as the root.
|
||||||
|
int context_help_count_in(GtkWidget* root, std::string* ids);
|
||||||
|
/// The size of the first *visible* hint button in a subtree, read the way the probe's geometry is
|
||||||
|
/// (`gtk_widget_compute_bounds()`), and the number a shrinking affordance is checked against. Both
|
||||||
|
/// are 0 when the layer is off or nothing on screen carries one.
|
||||||
|
void context_help_size_in(GtkWidget* root, int* width, int* height);
|
||||||
|
/// `context_help_size_in()` with `App::window` as the root.
|
||||||
|
void context_help_size(App& app, int* width, int* height);
|
||||||
|
|
||||||
|
/// Builds a tour's overlay children (the four dim rectangles, the spotlight outline and the card)
|
||||||
|
/// inside `overlay`. Called once, as the overlay is inserted, by either window that draws a tour.
|
||||||
|
void tour_build(TourView& view, GtkWidget* overlay);
|
||||||
|
/// Starts at the first stop. `stops` is the whole table, in walk order: the stops whose surface is
|
||||||
|
/// not on screen are filtered out here, by the one rule both windows use (a wrong page is kept and
|
||||||
|
/// selected, a collapsed pane is dropped). `on_done` (may be null) runs when the walk passes its last
|
||||||
|
/// stop, and is how the chooser hands off.
|
||||||
|
void tour_begin(TourView& view, std::vector<TourStop> stops, void (*on_done)(void* user),
|
||||||
|
void* done_user);
|
||||||
|
/// Moves `delta` stops, finishing the walk (and calling `on_done`) on the way past the last one.
|
||||||
|
void tour_advance(TourView& view, int delta);
|
||||||
|
/// Ends the walk without finishing it: every child of the overlay is hidden and nothing is left on
|
||||||
|
/// screen. Skip and Escape.
|
||||||
|
void tour_end(TourView& view);
|
||||||
|
/// Called from the tick while the walk runs: re-measures the target each frame, so a window resize
|
||||||
|
/// keeps the spotlight on the widget, and places the card where it does not cover it.
|
||||||
|
void tour_update(TourView& view);
|
||||||
|
/// The widget the current stop points at, or nullptr when the walk is not on a stop.
|
||||||
|
GtkWidget* tour_target(TourView& view);
|
||||||
|
|
||||||
|
/// Builds the *window's* tour inside `app.tour.overlay`. Called once, as the overlay is inserted.
|
||||||
|
void build_tour(App& app);
|
||||||
|
/// Starts the window's tour at its first step and writes `tour_seen`. Safe to call while it already
|
||||||
|
/// runs -- it restarts at step 0.
|
||||||
|
void start_tour(App& app);
|
||||||
|
/// Moves `delta` steps, ending the tour on the way past the last one.
|
||||||
|
void tour_step(App& app, int delta);
|
||||||
|
/// Ends the tour: every child of the overlay is hidden and nothing of the tour is left on screen.
|
||||||
|
void tour_dismiss(App& app);
|
||||||
|
/// The window's tour, re-measured each frame.
|
||||||
|
void update_tour(App& app);
|
||||||
|
bool tour_active(const App& app);
|
||||||
|
/// The widget the current tour step points at, or nullptr when the tour is not on a step. The probe
|
||||||
|
/// reads the spotlight against it.
|
||||||
|
GtkWidget* tour_target(App& app);
|
||||||
|
/// The steps this run actually walks (targets that were on screen when it started), and where it is.
|
||||||
|
int tour_step_count(const App& app);
|
||||||
|
int tour_step_index(const App& app);
|
||||||
|
/// The first-run auto-start's callback, scheduled only when `settings.tour_seen` is false.
|
||||||
|
gboolean tour_autostart(gpointer user);
|
||||||
|
/// The chooser's "Guided tour" request (`Config::tour_pending`): the same delayed start, but it
|
||||||
|
/// sets `tour_requested` rather than `tour_auto_started`, so the probe and the dump can tell the two
|
||||||
|
/// apart. Scheduled only when the config carries the request.
|
||||||
|
gboolean tour_start_requested(gpointer user);
|
||||||
|
/// Captures the layer's own readings and the bounds of the surfaces the help's figures label, while
|
||||||
|
/// the window is alive -- the dump runs after it is destroyed and may not ask GTK anything.
|
||||||
|
void capture_help_state(App& app);
|
||||||
|
|
||||||
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
|
int run_ui(int argc, char** argv, const Config& cfg, ScopeModel& model, Acquisition& acq,
|
||||||
RowSet& rows);
|
RowSet& rows);
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
//
|
||||||
|
// The contextual-help layer: one small "(i)" beside each surface the application can explain, whose
|
||||||
|
// bubble says what that surface is for, in a title, a sentence or two, and the key that reaches it
|
||||||
|
// when there is one.
|
||||||
|
//
|
||||||
|
// The surfaces are the table below -- a surface is a row -- and a hint *joins the surface's own
|
||||||
|
// row* rather than being overlaid on it: a sibling of the title, the switch or the pencil can never
|
||||||
|
// cover a control, which an overlay child in a corner can and does. Most rows name a panel's heading,
|
||||||
|
// which is where its switch and its pencil live; the rest name the row of a *specific* control (the
|
||||||
|
// "record events" switch, the search block) so a hint can sit beside the thing it explains. The one
|
||||||
|
// exception is the plot, which has no heading: its "(i)" is an overlay child of the overlay the
|
||||||
|
// layout editor already uses, so it costs nothing and sits over the plot's top-right corner.
|
||||||
|
//
|
||||||
|
// Off means *gone*, not greyed: the toggle removes the buttons instead of disabling them. Which is
|
||||||
|
// also why the buttons are found by a data key rather than kept in a list here -- the math editor's
|
||||||
|
// heading dies with its builder on every reopen, and a list of pointers into it would go stale.
|
||||||
|
//
|
||||||
|
// The same affordance serves a window that has no `App` -- the source chooser, which runs before one
|
||||||
|
// exists -- through `context_help_button()`, so both windows draw *one* button.
|
||||||
|
|
||||||
|
#include "ui_app.hpp"
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace ui_impl {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// The "(i)" glyph. Adwaita's `dialog-information-symbolic` is the plain information mark -- a circle
|
||||||
|
/// with a lower-case i -- and it is the one this layer means; `help-about-symbolic` is already the
|
||||||
|
/// status bar's information button, so a second thing meaning "help" needed a name of its own.
|
||||||
|
/// `dialog-question-symbolic` was the first choice and read as "what is this?" rather than as the
|
||||||
|
/// neutral information mark the ask names. The dump reads the name back through
|
||||||
|
/// `gtk_icon_theme_has_icon()` -- a hint button whose icon does not resolve is a *blank* control, the
|
||||||
|
/// failure the pane [edit] buttons already paid for once.
|
||||||
|
constexpr const char* kHintIcon = "dialog-information-symbolic";
|
||||||
|
/// The data key on a hint button, carrying the surface's id as the value. One key is both the mark
|
||||||
|
/// that says "this is a hint" and the name the probe and the dump report it by.
|
||||||
|
constexpr const char* kHintKey = "udpscope-help-hint";
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
/// One surface the contextual-help layer explains.
|
||||||
|
///
|
||||||
|
/// `host` is the box the "?" is appended to -- the surface's *heading row* for a panel, the
|
||||||
|
/// status line's row, the header bar's window-control slot -- or, for the plot, the `GtkOverlay` it
|
||||||
|
/// becomes an overlay child of. A null host is a surface whose host only exists while a dialog is
|
||||||
|
/// open: the math editor's, which `open_math_dialog()` states and `context_help_add()` uses.
|
||||||
|
struct HelpHint {
|
||||||
|
const char* id; ///< what the probe and the dump call this affordance
|
||||||
|
const char* title; ///< the bubble's bold first line
|
||||||
|
const char* body; ///< one short paragraph
|
||||||
|
const char* shortcut; ///< the key that reaches the surface, as it is printed, or ""
|
||||||
|
GtkWidget* App::* host;
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The table, which is the whole of the curation: a new surface is a row, and the heading row it
|
||||||
|
/// names must already exist in `App` (that is what `ui_app.hpp`'s help-layer fields are for).
|
||||||
|
constexpr HelpHint kHelpHints[] = {
|
||||||
|
{"plot", "The plot",
|
||||||
|
"Every signal you have switched on is drawn here, one row per group of traces, with all rows "
|
||||||
|
"sharing this one time axis. The wheel zooms about the pointer, a drag pans, and a right-click "
|
||||||
|
"on a row opens that plot's own menu.",
|
||||||
|
"", &App::area_overlay},
|
||||||
|
{"trigger", "The trigger",
|
||||||
|
"Freezes the view on an event you describe: a signal, a condition (an edge, a pulse width, a "
|
||||||
|
"window, a dropout), a level and the event window around it. Arm it and the view holds the "
|
||||||
|
"first event that matches, so a pulse that happens once a minute is readable at any window.",
|
||||||
|
"A", &App::trigger_head},
|
||||||
|
{"cursors", "The cursors",
|
||||||
|
"Two movable instruments read against the window on screen: two verticals for time, two "
|
||||||
|
"horizontals for the value axis, or the two crossings of each trace. What every plotted row "
|
||||||
|
"reads at the cursor is listed in this pane.",
|
||||||
|
"C", &App::cursors_head},
|
||||||
|
{"measures", "The measurements",
|
||||||
|
"Readings over the visible window: average, RMS, top, peak to peak, amplitude, frequency, a "
|
||||||
|
"threshold count and more. Statistics under a reading track how it has varied since the last "
|
||||||
|
"reset, without the plot having to be still.",
|
||||||
|
"", &App::measures_head},
|
||||||
|
{"math", "Math channels",
|
||||||
|
"A channel computed from the streamed ones in a small node graph: sources, gains, filters and "
|
||||||
|
"arithmetic, wired together and named here. It is a channel like any other, so it can be "
|
||||||
|
"plotted, measured, searched and exported.",
|
||||||
|
"", &App::math_head},
|
||||||
|
{"signals", "The signals",
|
||||||
|
"Every signal the source declares, each with the switch that acquires it. Nothing is buffered "
|
||||||
|
"until it is switched on, and only acquired signals appear in the drop targets and the "
|
||||||
|
"measurement editor. A signal that is dragged onto a plot is drawn there.",
|
||||||
|
"G", &App::signals_head},
|
||||||
|
{"events", "The event history",
|
||||||
|
"Every triggered acquisition the cache keeps, with a picture of each. Open one to read it as a "
|
||||||
|
"file, or select two or four and have them compared in two plots or overlaid in one.",
|
||||||
|
"", &App::events_head},
|
||||||
|
{"events_record", "Recording events",
|
||||||
|
"With this on, every acquisition the trigger latches is written to the event cache -- the "
|
||||||
|
"samples around it and a picture of it -- so an event can be read again long after it has left "
|
||||||
|
"the live buffer. It is the switch beside it, and the same one the events pane shows the result "
|
||||||
|
"of.",
|
||||||
|
"", &App::trigger_record_row},
|
||||||
|
{"trigger_advanced", "The trigger's finer settings",
|
||||||
|
"Pre-trigger share is how much of the event window is kept *before* the crossing, and hold-off "
|
||||||
|
"is the quiet time after a latch in which nothing new can be armed -- the two that decide what a "
|
||||||
|
"capture looks like. The condition rows above appear and disappear with the condition chosen.",
|
||||||
|
"", &App::trigger_pre_row},
|
||||||
|
{"trigger_search", "Searching the buffer",
|
||||||
|
"Applies the condition above to the whole retained ring -- the *same* evaluator the trigger runs, "
|
||||||
|
"so a hit is an instant the trigger would have fired on -- and lists every place it holds. The "
|
||||||
|
"arrows step through the hits, pausing centred on each, and up to sixteen in view are marked on "
|
||||||
|
"every row.",
|
||||||
|
"", &App::trigger_search},
|
||||||
|
{"signals_config", "Configuring signals",
|
||||||
|
"The list is locked until the pencil above opens the per-signal controls: the calibration that "
|
||||||
|
"maps raw counts to a unit, the colour a trace is drawn in, and which plot a signal is dragged "
|
||||||
|
"onto. A signal nothing measures can be switched off to stop acquiring it.",
|
||||||
|
"", &App::signals_note},
|
||||||
|
{"window", "The time window",
|
||||||
|
"How much time is on screen, counted back from the newest sample. Type a span, change its unit, "
|
||||||
|
"or step by decades; the trigger's event window is framed out of the same span. No gesture on "
|
||||||
|
"the plot changes it -- only this control does.",
|
||||||
|
"", &App::window_control_slot},
|
||||||
|
{"status", "The status line",
|
||||||
|
"The stream's own report, on its own once-a-second cadence: the display mode, the rate actually "
|
||||||
|
"arriving, the window on screen and the buffer holding it. The information button beside it "
|
||||||
|
"opens the counters behind those words.",
|
||||||
|
"", &App::status_row},
|
||||||
|
};
|
||||||
|
constexpr int kHelpHintCount = static_cast<int>(sizeof(kHelpHints) / sizeof(kHelpHints[0]));
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// The bubble: a bold title, the paragraph, and the key when there is one. Wrapped at a stated
|
||||||
|
/// width rather than left to the popover's own measure, or a long sentence is one very wide line.
|
||||||
|
GtkWidget* hint_popover(const char* title_text, const char* body_text, const char* shortcut) {
|
||||||
|
GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
|
||||||
|
gtk_widget_set_margin_start(box, 12);
|
||||||
|
gtk_widget_set_margin_end(box, 12);
|
||||||
|
gtk_widget_set_margin_top(box, 10);
|
||||||
|
gtk_widget_set_margin_bottom(box, 10);
|
||||||
|
|
||||||
|
GtkWidget* title = gtk_label_new(nullptr);
|
||||||
|
char markup[128];
|
||||||
|
std::snprintf(markup, sizeof(markup), "<b>%s</b>", title_text);
|
||||||
|
gtk_label_set_markup(GTK_LABEL(title), markup);
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(title), 0.0f);
|
||||||
|
gtk_box_append(GTK_BOX(box), title);
|
||||||
|
|
||||||
|
GtkWidget* body = gtk_label_new(body_text);
|
||||||
|
gtk_label_set_wrap(GTK_LABEL(body), TRUE);
|
||||||
|
gtk_label_set_max_width_chars(GTK_LABEL(body), 42);
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(body), 0.0f);
|
||||||
|
gtk_box_append(GTK_BOX(box), body);
|
||||||
|
|
||||||
|
if (shortcut != nullptr && shortcut[0] != '\0') {
|
||||||
|
const std::string key = std::string("Shortcut: ") + shortcut;
|
||||||
|
GtkWidget* line = gtk_label_new(key.c_str());
|
||||||
|
gtk_widget_add_css_class(line, "dim-label");
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(line), 0.0f);
|
||||||
|
gtk_box_append(GTK_BOX(box), line);
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* popover = gtk_popover_new();
|
||||||
|
gtk_popover_set_child(GTK_POPOVER(popover), box);
|
||||||
|
return popover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One "(i)" button, marked with its surface's id. `udpscope-hint` is what makes it *tiny*: the
|
||||||
|
/// default menu button is a 34 px control with 8 px of padding, which is the size that read as a
|
||||||
|
/// second toolbar button beside each heading. The class trims the box and the glyph together (see
|
||||||
|
/// `install_css()`), and `flat` keeps Adwaita from drawing it as a button until it is hovered.
|
||||||
|
GtkWidget* hint_button(const char* id, const char* title, const char* body, const char* shortcut) {
|
||||||
|
GtkWidget* button = gtk_menu_button_new();
|
||||||
|
gtk_menu_button_set_icon_name(GTK_MENU_BUTTON(button), kHintIcon);
|
||||||
|
gtk_menu_button_set_popover(GTK_MENU_BUTTON(button), hint_popover(title, body, shortcut));
|
||||||
|
gtk_widget_add_css_class(button, "flat");
|
||||||
|
gtk_widget_add_css_class(button, "udpscope-hint");
|
||||||
|
gtk_widget_set_valign(button, GTK_ALIGN_CENTER);
|
||||||
|
gtk_widget_set_tooltip_text(button, "What is this?");
|
||||||
|
g_object_set_data_full(G_OBJECT(button), kHintKey, g_strdup(id), g_free);
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HelpHint* hint_by_id(const char* id) {
|
||||||
|
for (int i = 0; i < kHelpHintCount; ++i) {
|
||||||
|
if (std::strcmp(kHelpHints[i].id, id) == 0) return &kHelpHints[i];
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Takes the hint off `host`, if it carries one. Dismissed first, so a popover that is still up is
|
||||||
|
/// not destroyed underneath GTK's own popdown path (a `GtkMenuButton` owns its popover, so the
|
||||||
|
/// button cannot be dropped while the bubble is showing).
|
||||||
|
void remove_hint(GtkWidget* host) {
|
||||||
|
if (host == nullptr) return;
|
||||||
|
for (GtkWidget* child = gtk_widget_get_first_child(host); child != nullptr;) {
|
||||||
|
GtkWidget* next = gtk_widget_get_next_sibling(child);
|
||||||
|
if (g_object_get_data(G_OBJECT(child), kHintKey) != nullptr) {
|
||||||
|
if (GTK_IS_MENU_BUTTON(child)) {
|
||||||
|
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(child));
|
||||||
|
if (popover != nullptr && gtk_widget_is_visible(GTK_WIDGET(popover))) {
|
||||||
|
gtk_popover_popdown(popover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// `gtk_widget_unparent()` rather than `gtk_box_remove()`: the host may be a box or the plot's
|
||||||
|
// overlay, and this is the one call that means the same thing for both.
|
||||||
|
gtk_widget_unparent(child);
|
||||||
|
}
|
||||||
|
child = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts the hint for one table row on `host`, or takes it off when the layer is off.
|
||||||
|
void sync_one(App& app, int row, GtkWidget* host) {
|
||||||
|
if (row < 0 || row >= kHelpHintCount || host == nullptr) return;
|
||||||
|
const HelpHint& hint = kHelpHints[row];
|
||||||
|
remove_hint(host);
|
||||||
|
if (!app.settings.contextual_help) return;
|
||||||
|
GtkWidget* button = hint_button(hint.id, hint.title, hint.body, hint.shortcut);
|
||||||
|
if (GTK_IS_BOX(host)) {
|
||||||
|
gtk_box_append(GTK_BOX(host), button);
|
||||||
|
} else if (GTK_IS_OVERLAY(host)) {
|
||||||
|
// The plot's own case: an overlay child in the corner, where the layout editor's buttons are not
|
||||||
|
// (they follow the rows, and the Done button is the *other* corner).
|
||||||
|
gtk_widget_set_halign(button, GTK_ALIGN_END);
|
||||||
|
gtk_widget_set_valign(button, GTK_ALIGN_START);
|
||||||
|
gtk_widget_set_margin_end(button, 8);
|
||||||
|
gtk_widget_set_margin_top(button, 8);
|
||||||
|
gtk_overlay_add_overlay(GTK_OVERLAY(host), button);
|
||||||
|
} else {
|
||||||
|
gtk_widget_set_parent(button, host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walks up from `widget` counting the hint buttons in the subtree, and collecting their ids.
|
||||||
|
int count_hints(GtkWidget* widget, std::string* ids) {
|
||||||
|
if (widget == nullptr) return 0;
|
||||||
|
int n = 0;
|
||||||
|
if (const char* id = static_cast<const char*>(g_object_get_data(G_OBJECT(widget), kHintKey))) {
|
||||||
|
if (ids != nullptr) {
|
||||||
|
if (!ids->empty()) ids->push_back(' ');
|
||||||
|
ids->append(id);
|
||||||
|
}
|
||||||
|
++n;
|
||||||
|
}
|
||||||
|
for (GtkWidget* child = gtk_widget_get_first_child(widget); child != nullptr;
|
||||||
|
child = gtk_widget_get_next_sibling(child)) {
|
||||||
|
n += count_hints(child, ids);
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first *visible* hint button in the subtree, which is the one whose allocation
|
||||||
|
/// `context_help_size_in()` reports -- every hint shares the one `udpscope-hint` size, so one of them
|
||||||
|
/// is the reading. Invisible ones are skipped: a hint inside a collapsed expander (the trigger's
|
||||||
|
/// condition rows live in one) is off screen and reads 0x0, which would make the number say the
|
||||||
|
/// affordance had shrunk to nothing.
|
||||||
|
GtkWidget* first_hint(GtkWidget* widget) {
|
||||||
|
if (widget == nullptr) return nullptr;
|
||||||
|
if (g_object_get_data(G_OBJECT(widget), kHintKey) != nullptr) {
|
||||||
|
return gtk_widget_is_visible(widget) ? widget : nullptr;
|
||||||
|
}
|
||||||
|
for (GtkWidget* child = gtk_widget_get_first_child(widget); child != nullptr;
|
||||||
|
child = gtk_widget_get_next_sibling(child)) {
|
||||||
|
if (GtkWidget* found = first_hint(child)) return found;
|
||||||
|
}
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void context_help_sync(App& app) {
|
||||||
|
for (int i = 0; i < kHelpHintCount; ++i) sync_one(app, i, app.*(kHelpHints[i].host));
|
||||||
|
}
|
||||||
|
|
||||||
|
void context_help_add(App& app, const char* id, GtkWidget* host) {
|
||||||
|
const HelpHint* hint = hint_by_id(id);
|
||||||
|
if (hint == nullptr) return;
|
||||||
|
sync_one(app, static_cast<int>(hint - kHelpHints), host);
|
||||||
|
}
|
||||||
|
|
||||||
|
void context_help_button(GtkWidget* host, const char* id, const char* title, const char* body,
|
||||||
|
const char* shortcut, bool on) {
|
||||||
|
if (host == nullptr || !GTK_IS_BOX(host)) return;
|
||||||
|
remove_hint(host);
|
||||||
|
if (!on) return;
|
||||||
|
gtk_box_append(GTK_BOX(host), hint_button(id, title, body, shortcut));
|
||||||
|
}
|
||||||
|
|
||||||
|
int context_help_count_in(GtkWidget* root, std::string* ids) {
|
||||||
|
if (ids != nullptr) ids->clear();
|
||||||
|
return count_hints(root, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
int context_help_count(App& app, std::string* ids) {
|
||||||
|
if (ids != nullptr) ids->clear();
|
||||||
|
if (app.window == nullptr) return 0;
|
||||||
|
return count_hints(app.window, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
void context_help_size_in(GtkWidget* root, int* width, int* height) {
|
||||||
|
if (width != nullptr) *width = 0;
|
||||||
|
if (height != nullptr) *height = 0;
|
||||||
|
GtkWidget* hint = first_hint(root);
|
||||||
|
if (hint == nullptr) return;
|
||||||
|
graphene_rect_t r = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
|
||||||
|
// `gtk_widget_compute_bounds()` -- the rectangle on screen -- and not `get_width()`: the latter is
|
||||||
|
// the widget's *content* box, a border and a padding short of what a reader sees, which is the
|
||||||
|
// measurement trap the tour card already paid for (see invariants-gtk-and-chrome.md).
|
||||||
|
if (!gtk_widget_compute_bounds(hint, root, &r)) return;
|
||||||
|
if (width != nullptr) *width = static_cast<int>(std::lround(r.size.width));
|
||||||
|
if (height != nullptr) *height = static_cast<int>(std::lround(r.size.height));
|
||||||
|
}
|
||||||
|
|
||||||
|
void context_help_size(App& app, int* width, int* height) {
|
||||||
|
if (app.window == nullptr) {
|
||||||
|
if (width != nullptr) *width = 0;
|
||||||
|
if (height != nullptr) *height = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context_help_size_in(app.window, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
void on_contextual_help_toggled(GtkToggleButton* button, gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
const bool on = gtk_toggle_button_get_active(button) != FALSE;
|
||||||
|
if (on == app->settings.contextual_help) return;
|
||||||
|
app->settings.contextual_help = on;
|
||||||
|
// Through the one mechanism the preferences already use: the settings file, written in full so the
|
||||||
|
// keys this toggle does not own are carried through from the value the app is running on.
|
||||||
|
if (!save_settings(app->settings)) {
|
||||||
|
std::fprintf(stderr, "[help] could not write %s: the toggle will not survive a restart\n",
|
||||||
|
settings_path().c_str());
|
||||||
|
}
|
||||||
|
context_help_sync(*app);
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ui_impl
|
||||||
|
} // namespace udpscope
|
||||||
@@ -109,6 +109,10 @@ GtkWidget* build_cursors_panel(App& app) {
|
|||||||
return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
return box != nullptr ? box : gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The panel's heading row -- its first child, which carries the title, the mode dropdown, Reset and
|
||||||
|
// the pane's switch. It is where the contextual help's "?" joins (see `ui_context_help.cpp`).
|
||||||
|
app.cursors_head = gtk_widget_get_first_child(box);
|
||||||
|
|
||||||
// The mode names are the dropdown's model, and they are a C array, so they are set here.
|
// The mode names are the dropdown's model, and they are a C array, so they are set here.
|
||||||
const char* names[5] = {kCursorModeNames[0], kCursorModeNames[1], kCursorModeNames[2],
|
const char* names[5] = {kCursorModeNames[0], kCursorModeNames[1], kCursorModeNames[2],
|
||||||
kCursorModeNames[3], nullptr};
|
kCursorModeNames[3], nullptr};
|
||||||
|
|||||||
@@ -986,6 +986,8 @@ GtkWidget* build_events_pane(App& app) {
|
|||||||
app.events_accumulate = GTK_WIDGET(gtk_builder_get_object(ui, "events_accumulate"));
|
app.events_accumulate = GTK_WIDGET(gtk_builder_get_object(ui, "events_accumulate"));
|
||||||
GtkWidget* live = GTK_WIDGET(gtk_builder_get_object(ui, "events_live"));
|
GtkWidget* live = GTK_WIDGET(gtk_builder_get_object(ui, "events_live"));
|
||||||
app.events_list = GTK_WIDGET(gtk_builder_get_object(ui, "events_list"));
|
app.events_list = GTK_WIDGET(gtk_builder_get_object(ui, "events_list"));
|
||||||
|
// The heading row, where the contextual help's "?" joins (see `ui_context_help.cpp`).
|
||||||
|
app.events_head = GTK_WIDGET(gtk_builder_get_object(ui, "events_head"));
|
||||||
if (app.events_revealer == nullptr || app.events_compare == nullptr ||
|
if (app.events_revealer == nullptr || app.events_compare == nullptr ||
|
||||||
app.events_accumulate == nullptr || live == nullptr || app.events_list == nullptr) {
|
app.events_accumulate == nullptr || live == nullptr || app.events_list == nullptr) {
|
||||||
// A missing id is a mistake in the `.ui`: the pane comes up empty rather than taking the window with it,
|
// A missing id is a mistake in the `.ui`: the pane comes up empty rather than taking the window with it,
|
||||||
|
|||||||
@@ -59,6 +59,30 @@ LayoutState collect_layout(App& app) {
|
|||||||
st.measures = app.measures;
|
st.measures = app.measures;
|
||||||
st.measure_stats = app.measure_stats_on;
|
st.measure_stats = app.measure_stats_on;
|
||||||
st.side_tab = app.side_tab;
|
st.side_tab = app.side_tab;
|
||||||
|
// The trigger, its settings *and* whether it was on. The channel goes by name, like every identity
|
||||||
|
// in this file: every rebuild renumbers the channels.
|
||||||
|
{
|
||||||
|
const TriggerConfig& tc = app.model->trigger();
|
||||||
|
const std::vector<ChannelInfo>& channels = app.model->channels();
|
||||||
|
st.trigger.enabled = tc.enabled;
|
||||||
|
if (tc.channel >= 0 && tc.channel < static_cast<int>(channels.size())) {
|
||||||
|
st.trigger.channel = channels[static_cast<std::size_t>(tc.channel)].name;
|
||||||
|
}
|
||||||
|
st.trigger.type = trigger_type_text(tc.condition.type);
|
||||||
|
st.trigger.edge = trigger_edge_text(tc.condition.edge);
|
||||||
|
st.trigger.cmp = width_cmp_text(tc.condition.cmp);
|
||||||
|
st.trigger.mode = trigger_mode_text(tc.mode);
|
||||||
|
st.trigger.level = tc.condition.level;
|
||||||
|
st.trigger.level2 = tc.condition.level2;
|
||||||
|
st.trigger.width_s = tc.condition.width_s;
|
||||||
|
st.trigger.width2_s = tc.condition.width2_s;
|
||||||
|
st.trigger.timeout_s = tc.condition.timeout_s;
|
||||||
|
st.trigger.pretrigger_pct = tc.pretrigger_pct;
|
||||||
|
st.trigger.holdoff_s = tc.holdoff_s;
|
||||||
|
st.trigger.noise_band = tc.condition.noise_band;
|
||||||
|
st.trigger.hf_fc = tc.condition.hf_fc;
|
||||||
|
st.trigger.level_given = true; // written means given, so a load keeps it
|
||||||
|
}
|
||||||
// The connection is the session's too: the file's name carries only host and port.
|
// The connection is the session's too: the file's name carries only host and port.
|
||||||
st.multicast = app.source_multicast;
|
st.multicast = app.source_multicast;
|
||||||
st.interface_addr = app.source_interface;
|
st.interface_addr = app.source_interface;
|
||||||
@@ -289,6 +313,56 @@ void schedule_layout_save(App& app) {
|
|||||||
/// Applies a loaded layout: rows, slot assignments, calibration, window. Names
|
/// Applies a loaded layout: rows, slot assignments, calibration, window. Names
|
||||||
/// that no longer exist in the stream are skipped rather than freeing their slot
|
/// that no longer exist in the stream are skipped rather than freeing their slot
|
||||||
/// (a stream that renamed a signal must not blank the user's layout).
|
/// (a stream that renamed a signal must not blank the user's layout).
|
||||||
|
/// The session's trigger into the model and the panel, called from `apply_layout()`. The *channel* it
|
||||||
|
/// names is resolved where the channels exist -- `rebuild_trigger_signals()` picks up
|
||||||
|
/// `App::pending_trigger_channel` -- because at this point the stream may not have described its
|
||||||
|
/// signals yet, and a name that resolves to nothing would silently move the trigger to channel 0.
|
||||||
|
void apply_trigger_layout(App& app, const LayoutState::Trigger& t) {
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||||
|
TriggerConfig& tc = app.model->trigger();
|
||||||
|
tc.condition.type = trigger_type_from_text(t.type);
|
||||||
|
tc.condition.edge = trigger_edge_from_text(t.edge);
|
||||||
|
tc.condition.cmp = width_cmp_from_text(t.cmp);
|
||||||
|
tc.mode = trigger_mode_from_text(t.mode);
|
||||||
|
tc.condition.level = t.level;
|
||||||
|
tc.condition.level2 = t.level2;
|
||||||
|
tc.condition.width_s = t.width_s;
|
||||||
|
tc.condition.width2_s = t.width2_s;
|
||||||
|
tc.condition.timeout_s = t.timeout_s;
|
||||||
|
tc.condition.noise_band = t.noise_band;
|
||||||
|
tc.condition.hf_fc = t.hf_fc;
|
||||||
|
tc.pretrigger_pct = t.pretrigger_pct;
|
||||||
|
tc.holdoff_s = t.holdoff_s;
|
||||||
|
}
|
||||||
|
// A file that carried a level keeps it: the panel's own default follows the channel's range, and
|
||||||
|
// `trigger_level_touched` is what says the user has a level of their own.
|
||||||
|
if (t.level_given) app.trigger_level_touched = true;
|
||||||
|
app.pending_trigger_channel = t.channel;
|
||||||
|
{
|
||||||
|
// If the stream has already described its channels, resolve the name *now*: the signals rebuild that
|
||||||
|
// would otherwise do it has already been and gone, so a pending name would leave the trigger on
|
||||||
|
// channel 0 -- which is a trigger on whichever signal happens to be first.
|
||||||
|
const std::vector<ChannelInfo>& channels = app.model->channels();
|
||||||
|
for (std::size_t c = 0; c < channels.size(); ++c) {
|
||||||
|
if (channels[c].name != app.pending_trigger_channel) continue;
|
||||||
|
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||||
|
app.model->trigger().channel = static_cast<int>(c);
|
||||||
|
app.pending_trigger_channel.clear();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_trigger_widgets(app);
|
||||||
|
// The state is applied by *pressing the same control a user would*: the handler is what arms, tells
|
||||||
|
// the events button, greys the body and syncs the pane's own switch. Deliberately outside the
|
||||||
|
// `updating_panels` window `apply_layout()` uses for the panels, because a toggle set inside it
|
||||||
|
// would emit `toggled` into a handler that returns at once and nothing would happen.
|
||||||
|
if (app.trigger_toggle != nullptr) {
|
||||||
|
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app.trigger_toggle), t.enabled);
|
||||||
|
}
|
||||||
|
sync_panel_switches(app);
|
||||||
|
}
|
||||||
|
|
||||||
void apply_layout(App& app, const LayoutState& st) {
|
void apply_layout(App& app, const LayoutState& st) {
|
||||||
ScopeModel& model = *app.model;
|
ScopeModel& model = *app.model;
|
||||||
const auto& signals = model.signals();
|
const auto& signals = model.signals();
|
||||||
@@ -410,6 +484,17 @@ void apply_layout(App& app, const LayoutState& st) {
|
|||||||
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
|
if (app.area != nullptr) app.area->set_figure(&app.rows->figure());
|
||||||
app.relayout_pending = true;
|
app.relayout_pending = true;
|
||||||
rebuild_signal_panels(app);
|
rebuild_signal_panels(app);
|
||||||
|
// The trigger last: its channel names an *acquired* channel, so it is resolved once the enables
|
||||||
|
// above have built the channel list.
|
||||||
|
apply_trigger_layout(app, st.trigger);
|
||||||
|
|
||||||
|
// A session that loaded is a user who has been here before, and that is what the lock means: the two
|
||||||
|
// panes come up showing what they are set to rather than showing every control. A first run -- no
|
||||||
|
// file for this source -- never reaches this function and stays in configuration mode, which is what
|
||||||
|
// a first run is for.
|
||||||
|
app.trigger_locked = true;
|
||||||
|
app.signals_locked = true;
|
||||||
|
apply_pane_lock(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
void on_layout_row_remove(GtkButton* button, gpointer user) {
|
void on_layout_row_remove(GtkButton* button, gpointer user) {
|
||||||
|
|||||||
+26
-3
@@ -118,15 +118,24 @@ void measure_dialog_sync(MeasureDialog* d) {
|
|||||||
"show on plot (ruler at the value, marker where it is)");
|
"show on plot (ruler at the value, marker where it is)");
|
||||||
break;
|
break;
|
||||||
case MeasureType::PeakToPeak:
|
case MeasureType::PeakToPeak:
|
||||||
case MeasureType::Amplitude:
|
|
||||||
gtk_label_set_text(GTK_LABEL(d->threshold_label), "");
|
gtk_label_set_text(GTK_LABEL(d->threshold_label), "");
|
||||||
gtk_label_set_text(GTK_LABEL(d->side_label), "");
|
gtk_label_set_text(GTK_LABEL(d->side_label), "");
|
||||||
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
|
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
|
||||||
"show on plot (rulers at both extremes)");
|
"show on plot (rulers at both extremes)");
|
||||||
break;
|
break;
|
||||||
|
case MeasureType::Amplitude:
|
||||||
|
gtk_label_set_text(GTK_LABEL(d->threshold_label), "");
|
||||||
|
gtk_label_set_text(GTK_LABEL(d->side_label), "");
|
||||||
|
// Amplitude is the delta between the two *flat* levels, so its rulers are those two and not
|
||||||
|
// the extremes a peak-to-peak draws: the pair of rulers is the reading.
|
||||||
|
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show),
|
||||||
|
"show on plot (rulers at both flat levels)");
|
||||||
|
break;
|
||||||
case MeasureType::Top:
|
case MeasureType::Top:
|
||||||
case MeasureType::Bottom:
|
case MeasureType::Bottom:
|
||||||
gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range");
|
// The split picks the *side*: the level is the densest band either above or below it, so any
|
||||||
|
// split between the two plateaus reads the same -- it is not a mean over the side any more.
|
||||||
|
gtk_label_set_text(GTK_LABEL(d->threshold_label), "split % of the range (picks the side)");
|
||||||
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the level)");
|
gtk_check_button_set_label(GTK_CHECK_BUTTON(d->show), "show on plot (ruler at the level)");
|
||||||
break;
|
break;
|
||||||
case MeasureType::PulseDuration:
|
case MeasureType::PulseDuration:
|
||||||
@@ -320,6 +329,12 @@ void update_measures(App& app, bool force, bool accumulate) {
|
|||||||
}
|
}
|
||||||
want_window[i] = static_cast<int>(wi);
|
want_window[i] = static_cast<int>(wi);
|
||||||
App::MeasureWindow& w = app.measure_samples[wi];
|
App::MeasureWindow& w = app.measure_samples[wi];
|
||||||
|
// The three types whose value is a flat level: pass one folds the bucket histogram for this
|
||||||
|
// window only if one of them is in the pane.
|
||||||
|
if (m.type == MeasureType::Top || m.type == MeasureType::Bottom ||
|
||||||
|
m.type == MeasureType::Amplitude) {
|
||||||
|
w.flat_wanted = true;
|
||||||
|
}
|
||||||
double pct = 0.0;
|
double pct = 0.0;
|
||||||
if (!measure_level_pct(m.type, m.options, pct)) continue;
|
if (!measure_level_pct(m.type, m.options, pct)) continue;
|
||||||
// A type that does not read the band must not split the accumulator over it: its sums split at the bare
|
// A type that does not read the band must not split the accumulator over it: its sums split at the bare
|
||||||
@@ -348,6 +363,12 @@ void update_measures(App& app, bool force, bool accumulate) {
|
|||||||
// for the samples produced since this frame's `acquire_all()`, and for those straddling
|
// for the samples produced since this frame's `acquire_all()`, and for those straddling
|
||||||
// buckets; a window shorter than a bucket, or a signal noisy enough to straddle everywhere,
|
// buckets; a window shorter than a bucket, or a signal noisy enough to straddle everywhere,
|
||||||
// degrades back to the O(window) cost and nothing else changes.
|
// degrades back to the O(window) cost and nothing else changes.
|
||||||
|
//
|
||||||
|
// The flat-level types (`top`/`bottom`/`amplitude`) are the one thing that reads a *third* time
|
||||||
|
// -- and it is the same fetched bucket list, not a third fetch: pass one's bucket walk folds its
|
||||||
|
// own histogram while it has the buckets in hand (see `measure_scan_buckets`). It is O(buckets)
|
||||||
|
// and it reads no sample, so `raw` does not move; a pane with none of those three types does
|
||||||
|
// not pay for it at all.
|
||||||
constexpr std::size_t kChunk = 8192;
|
constexpr std::size_t kChunk = 8192;
|
||||||
std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch;
|
std::vector<MeasureBucket>& buckets = app.measure_bucket_scratch;
|
||||||
for (App::MeasureWindow& w : app.measure_samples) {
|
for (App::MeasureWindow& w : app.measure_samples) {
|
||||||
@@ -385,7 +406,9 @@ void update_measures(App& app, bool force, bool accumulate) {
|
|||||||
|
|
||||||
const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats, w.scan_filter); };
|
const auto scan = [&](const MeasureChunk& c) { measure_scan(c, w.stats, w.scan_filter); };
|
||||||
raw(0, bulk_from, scan);
|
raw(0, bulk_from, scan);
|
||||||
if (!buckets.empty()) measure_scan_buckets(buckets.data(), buckets.size(), w.stats);
|
if (!buckets.empty()) {
|
||||||
|
measure_scan_buckets(buckets.data(), buckets.size(), w.stats, w.flat_wanted);
|
||||||
|
}
|
||||||
raw(bulk_to, n, scan);
|
raw(bulk_to, n, scan);
|
||||||
// A bucket named the 64 samples that hold an extreme, not the sample. One scan of that bucket
|
// A bucket named the 64 samples that hold an extreme, not the sample. One scan of that bucket
|
||||||
// pins it -- two buckets at most, whatever the window's size.
|
// pins it -- two buckets at most, whatever the window's size.
|
||||||
|
|||||||
+573
-15
@@ -85,6 +85,53 @@ gboolean exit_probe_step3(gpointer user) {
|
|||||||
a->dbg_switcher_w = gtk_widget_get_width(a->side_switcher);
|
a->dbg_switcher_w = gtk_widget_get_width(a->side_switcher);
|
||||||
a->dbg_switcher_h = gtk_widget_get_height(a->side_switcher);
|
a->dbg_switcher_h = gtk_widget_get_height(a->side_switcher);
|
||||||
}
|
}
|
||||||
|
// How many rows of the signal list carry a drag source: dragging a signal onto a plot is how a plot is
|
||||||
|
// filled, so it has to survive both pane modes -- the locked list drops the tick, the menu and the `+`,
|
||||||
|
// and this is the reading that says it did not drop the drag with them. One row is the `MATH` divider,
|
||||||
|
// which is not draggable: the count must be `rows - 1`.
|
||||||
|
if (a->signal_list != nullptr) {
|
||||||
|
int sources = 0;
|
||||||
|
for (GtkWidget* r = gtk_widget_get_first_child(a->signal_list); r != nullptr;
|
||||||
|
r = gtk_widget_get_next_sibling(r)) {
|
||||||
|
GListModel* controllers = gtk_widget_observe_controllers(r);
|
||||||
|
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
|
||||||
|
for (guint i = 0; i < n; ++i) {
|
||||||
|
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
|
||||||
|
if (GTK_IS_DRAG_SOURCE(c)) ++sources;
|
||||||
|
g_object_unref(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a->dbg_signal_drag_sources = sources;
|
||||||
|
}
|
||||||
|
// A drag out of an *overlaid* sidebar: the sidebar has to step aside or the drop has nowhere to land.
|
||||||
|
// The state is forced the way the sidebar probe forces it -- collapsed is what a window with no room
|
||||||
|
// beside the plot produces -- and the drag is begun through the *source*, so the handler is the one a
|
||||||
|
// real drag reaches.
|
||||||
|
if (a->side_view != nullptr && a->signal_list != nullptr) {
|
||||||
|
AdwOverlaySplitView* split = ADW_OVERLAY_SPLIT_VIEW(a->side_view);
|
||||||
|
GtkWidget* row = gtk_widget_get_first_child(a->signal_list);
|
||||||
|
adw_overlay_split_view_set_collapsed(split, TRUE);
|
||||||
|
adw_overlay_split_view_set_show_sidebar(split, TRUE);
|
||||||
|
if (row != nullptr) {
|
||||||
|
GListModel* controllers = gtk_widget_observe_controllers(row);
|
||||||
|
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
|
||||||
|
for (guint i = 0; i < n; ++i) {
|
||||||
|
GObject* c = G_OBJECT(g_list_model_get_item(controllers, i));
|
||||||
|
if (GTK_IS_DRAG_SOURCE(c)) {
|
||||||
|
g_signal_emit_by_name(c, "drag-begin", nullptr);
|
||||||
|
a->dbg_drag_hid_sidebar =
|
||||||
|
adw_overlay_split_view_get_show_sidebar(split) == FALSE ? 1 : 0;
|
||||||
|
row_drag_sidebar(*a, false);
|
||||||
|
a->dbg_drag_restored_sidebar =
|
||||||
|
adw_overlay_split_view_get_show_sidebar(split) != FALSE ? 1 : 0;
|
||||||
|
}
|
||||||
|
g_object_unref(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Left as the window's own width decides, so the readings taken before this are the ones that stand.
|
||||||
|
adw_overlay_split_view_set_collapsed(split, FALSE);
|
||||||
|
adw_overlay_split_view_set_show_sidebar(split, TRUE);
|
||||||
|
}
|
||||||
// The panes' own switches, and *both* directions of the sync: the header bar's trigger button is
|
// The panes' own switches, and *both* directions of the sync: the header bar's trigger button is
|
||||||
// flipped here and the pane's switch has to follow, then the cursors' switch is driven from the
|
// flipped here and the pane's switch has to follow, then the cursors' switch is driven from the
|
||||||
// pane and the button is what has to follow. Both are read back after the fact, so the two pairs in
|
// pane and the button is what has to follow. Both are read back after the fact, so the two pairs in
|
||||||
@@ -159,6 +206,44 @@ gboolean exit_probe_step3(gpointer user) {
|
|||||||
return G_SOURCE_REMOVE;
|
return G_SOURCE_REMOVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The frame after the pane was locked: read the compact arm control's *allocation*, then leave the
|
||||||
|
/// configuration mode and trigger mode as step 2 used to, and hand the chain back its 400 ms. The button
|
||||||
|
/// is created hidden and this is the first tick it is shown, so its width in the same tick would always
|
||||||
|
/// read 0 -- a number that says "not yet laid out" rather than "not on the surface".
|
||||||
|
gboolean compact_arm_step(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
GtkWidget* b = a->trigger_summary_arm;
|
||||||
|
a->dbg_summary_arm_width = b != nullptr ? gtk_widget_get_width(b) : -1;
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[udpscope] compact arm probe: locked=%d enabled=%d own=%d effective=%d "
|
||||||
|
"width=%d panel_width=%d label=%s\n",
|
||||||
|
a->trigger_locked ? 1 : 0,
|
||||||
|
a->trigger_toggle != nullptr &&
|
||||||
|
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->trigger_toggle)) != FALSE
|
||||||
|
? 1 : 0,
|
||||||
|
b != nullptr && gtk_widget_get_visible(b) ? 1 : 0,
|
||||||
|
b != nullptr && gtk_widget_is_visible(b) ? 1 : 0, a->dbg_summary_arm_width,
|
||||||
|
// The body's own Arm button, read only if it is itself on screen: with the pane locked it
|
||||||
|
// is not, and its stale allocation would read as if it were. -1 is "not the one on screen".
|
||||||
|
// `is_visible`, not `get_visible`: the flag is the widget's own and stays true while its
|
||||||
|
// *body* is hidden, which reported the body button's stale allocation as a live one.
|
||||||
|
a->arm_button != nullptr && gtk_widget_is_visible(a->arm_button)
|
||||||
|
? gtk_widget_get_width(a->arm_button)
|
||||||
|
: -1,
|
||||||
|
a->dbg_summary_arm_label.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
if (a->trigger_edit != nullptr) {
|
||||||
|
g_signal_emit_by_name(a->trigger_edit, "clicked"); // and back to the view only mode
|
||||||
|
}
|
||||||
|
if (a->trigger_toggle != nullptr) {
|
||||||
|
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE);
|
||||||
|
}
|
||||||
|
// The tick keeps running for these 400 ms, so by step 3 the status line reads
|
||||||
|
// the state the app is actually in after leaving trigger mode.
|
||||||
|
g_timeout_add(400, exit_probe_step3, a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
gboolean exit_probe_step2(gpointer user) {
|
gboolean exit_probe_step2(gpointer user) {
|
||||||
App* a = static_cast<App*>(user);
|
App* a = static_cast<App*>(user);
|
||||||
a->dbg_mode_triggered = a->status_detail;
|
a->dbg_mode_triggered = a->status_detail;
|
||||||
@@ -168,12 +253,21 @@ gboolean exit_probe_step2(gpointer user) {
|
|||||||
a->dbg_trigger_time = a->model->trigger_time().load();
|
a->dbg_trigger_time = a->model->trigger_time().load();
|
||||||
a->dbg_trigger_time_valid = a->model->trigger_time_valid().load() ? 1 : 0;
|
a->dbg_trigger_time_valid = a->model->trigger_time_valid().load() ? 1 : 0;
|
||||||
}
|
}
|
||||||
if (a->trigger_toggle != nullptr) {
|
// The combination the compact control exists for: the trigger *on*, the pane *locked*, so the body's
|
||||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->trigger_toggle), FALSE);
|
// own Arm button has gone with the body. Locking is the [edit] button's click, because that is what a
|
||||||
|
// user does, and the two properties read are the two the user sees. The label is checked against the
|
||||||
|
// engine's armed state rather than against a word: a single-shot latch can have disarmed it by now.
|
||||||
|
if (a->trigger_edit != nullptr) {
|
||||||
|
g_signal_emit_by_name(a->trigger_edit, "clicked"); // into configuration mode
|
||||||
|
GtkWidget* b = a->trigger_summary_arm;
|
||||||
|
a->dbg_summary_arm_visible = b != nullptr && gtk_widget_get_visible(b) ? 1 : 0;
|
||||||
|
a->dbg_summary_arm_sensitive = b != nullptr && gtk_widget_get_sensitive(b) ? 1 : 0;
|
||||||
|
const char* text = b != nullptr && GTK_IS_BUTTON(b) ? gtk_button_get_label(GTK_BUTTON(b)) : nullptr;
|
||||||
|
a->dbg_summary_arm_label = text != nullptr ? text : "-";
|
||||||
}
|
}
|
||||||
// The tick keeps running for these 400 ms, so by step 3 the status line reads
|
// The pane stays locked for one more frame: the allocation the next step reads is only meaningful once
|
||||||
// the state the app is actually in after leaving trigger mode.
|
// a frame has run with the control shown. Leaving trigger mode is that step's job now.
|
||||||
g_timeout_add(400, exit_probe_step3, a);
|
g_timeout_add(400, compact_arm_step, a);
|
||||||
return G_SOURCE_REMOVE;
|
return G_SOURCE_REMOVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1082,7 +1176,8 @@ gboolean measure_probe(gpointer user) {
|
|||||||
// The signal this probe measures is enabled *here*, for the same reason the noise probe enables its own:
|
// The signal this probe measures is enabled *here*, for the same reason the noise probe enables its own:
|
||||||
// a probe must not depend on another probe's side effects. `enable_all_probe` ends by switching a signal
|
// a probe must not depend on another probe's side effects. `enable_all_probe` ends by switching a signal
|
||||||
// off (it reads what a disabled signal does to the plots), and with `sine` gone the readings below were
|
// off (it reads what a disabled signal does to the plots), and with `sine` gone the readings below were
|
||||||
// taken on `square` -- where `top` is 1.0, not the 2/pi the documented baseline is.
|
// taken on `square` -- where `top` is its plateau's 1.0 rather than the sine's flat level, and the numbers
|
||||||
|
// the docs quote are the sine's.
|
||||||
{
|
{
|
||||||
const std::vector<SignalInfo>& signals = a->model->signals();
|
const std::vector<SignalInfo>& signals = a->model->signals();
|
||||||
for (std::size_t i = 0; i < signals.size(); ++i) {
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
||||||
@@ -2298,11 +2393,10 @@ gboolean export_probe(gpointer user) {
|
|||||||
std::fprintf(stderr, "[export-probe] column '%s' n=%zu\n", col.name.c_str(), col.values.size());
|
std::fprintf(stderr, "[export-probe] column '%s' n=%zu\n", col.name.c_str(), col.values.size());
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const char* ext : {".npy", ".csv"}) {
|
for (const char* ext : {".npy", ".csv", ".mf3"}) {
|
||||||
const std::string path = prefix + ext;
|
const std::string path = prefix + ext;
|
||||||
std::string error;
|
std::string error;
|
||||||
const bool ok = std::strcmp(ext, ".csv") == 0 ? write_csv(path, table, error)
|
const bool ok = write_export(path, table, error);
|
||||||
: write_npy(path, table, error);
|
|
||||||
long bytes = -1;
|
long bytes = -1;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
if (std::FILE* f = std::fopen(path.c_str(), "rb"); f != nullptr) {
|
if (std::FILE* f = std::fopen(path.c_str(), "rb"); f != nullptr) {
|
||||||
@@ -2314,6 +2408,18 @@ gboolean export_probe(gpointer user) {
|
|||||||
std::fprintf(stderr, "[export-probe] %s %s bytes=%ld%s%s\n", path.c_str(),
|
std::fprintf(stderr, "[export-probe] %s %s bytes=%ld%s%s\n", path.c_str(),
|
||||||
ok ? "written" : "FAILED", bytes, ok ? "" : " error=", ok ? "" : error.c_str());
|
ok ? "written" : "FAILED", bytes, ok ? "" : " error=", ok ? "" : error.c_str());
|
||||||
}
|
}
|
||||||
|
// What the `.mf3` was handed, in the shape the file stores it: the master and the relative
|
||||||
|
// axis are channels too, and `record` is the bytes of one interleaved record. So the size the
|
||||||
|
// line above printed is `272 + comment + rows*record + 28 + channels*274 + 30`, and the units
|
||||||
|
// are the ones ScryLab reads each signal's unit from -- the CONFIG's, per column.
|
||||||
|
std::string units;
|
||||||
|
for (const char* u : {"s", "s"}) units += std::string(units.empty() ? "" : " ") + "'" + u + "'";
|
||||||
|
for (const ExportTable::Column& col : table.columns) {
|
||||||
|
units += std::string(units.empty() ? "" : " ") + "'" + col.unit + "'";
|
||||||
|
}
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[export-probe] mdf master=time master_unit=s channels=%zu record=%zu units=[%s]\n",
|
||||||
|
table.columns.size() + 2, 16 + 4 * table.columns.size(), units.c_str());
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
return G_SOURCE_REMOVE;
|
return G_SOURCE_REMOVE;
|
||||||
}
|
}
|
||||||
@@ -2322,6 +2428,31 @@ gboolean exit_probe_step1(gpointer user) {
|
|||||||
App* a = static_cast<App*>(user);
|
App* a = static_cast<App*>(user);
|
||||||
// Record the panel states before the toggles are exercised.
|
// Record the panel states before the toggles are exercised.
|
||||||
a->dbg_trigger_sensitive_default = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0;
|
a->dbg_trigger_sensitive_default = gtk_widget_get_sensitive(a->trigger_body) ? 1 : 0;
|
||||||
|
// The two [edit] buttons, in each mode: the icon is the only thing that says which mode the pane is
|
||||||
|
// in, and an icon the theme does not have draws nothing at all.
|
||||||
|
const auto pane_icon = [](GtkWidget* b) {
|
||||||
|
const char* n = b != nullptr && GTK_IS_BUTTON(b) ? gtk_button_get_icon_name(GTK_BUTTON(b)) : nullptr;
|
||||||
|
return std::string(n != nullptr ? n : "-");
|
||||||
|
};
|
||||||
|
a->dbg_pane_edit_icon = pane_icon(a->trigger_edit);
|
||||||
|
if (a->trigger_edit != nullptr) {
|
||||||
|
g_signal_emit_by_name(a->trigger_edit, "clicked"); // into configuration mode
|
||||||
|
a->dbg_pane_view_icon = pane_icon(a->trigger_edit);
|
||||||
|
g_signal_emit_by_name(a->trigger_edit, "clicked"); // and back to the view
|
||||||
|
}
|
||||||
|
a->dbg_trigger_locked = a->trigger_locked ? 1 : 0;
|
||||||
|
a->dbg_signals_locked = a->signals_locked ? 1 : 0;
|
||||||
|
a->dbg_trigger_body_visible =
|
||||||
|
a->trigger_body != nullptr && gtk_widget_get_visible(a->trigger_body) ? 1 : 0;
|
||||||
|
// The summary's text comes from the sync that wrote it: the container is a grid of key/value rows,
|
||||||
|
// and asking it for a label's text was itself a critical.
|
||||||
|
a->dbg_trigger_summary_early = a->dbg_trigger_summary; // as found, before this probe touches it
|
||||||
|
a->dbg_arm_sensitive = a->arm_button != nullptr && gtk_widget_get_sensitive(a->arm_button) ? 1 : 0;
|
||||||
|
// The compact line's arm control must be *absent* here: the trigger is off, the line beside it reads
|
||||||
|
// "stream (trigger off)", and a trigger that is off has no engine to arm. Read before the toggle, which
|
||||||
|
// is where that absence is the assertion -- and it is the pair to the reading in step 2.
|
||||||
|
a->dbg_summary_arm_off =
|
||||||
|
a->trigger_summary_arm != nullptr && gtk_widget_get_visible(a->trigger_summary_arm) ? 1 : 0;
|
||||||
a->dbg_panel_switch_live =
|
a->dbg_panel_switch_live =
|
||||||
a->trigger_switch != nullptr && gtk_widget_get_sensitive(a->trigger_switch) &&
|
a->trigger_switch != nullptr && gtk_widget_get_sensitive(a->trigger_switch) &&
|
||||||
a->cursors_switch != nullptr && gtk_widget_get_sensitive(a->cursors_switch)
|
a->cursors_switch != nullptr && gtk_widget_get_sensitive(a->cursors_switch)
|
||||||
@@ -2832,9 +2963,12 @@ gboolean menus_probe_step(gpointer user) {
|
|||||||
if (a->dbg_menus_step == 1) {
|
if (a->dbg_menus_step == 1) {
|
||||||
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
|
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
|
||||||
GtkWidget* box = popover != nullptr ? gtk_popover_get_child(popover) : nullptr;
|
GtkWidget* box = popover != nullptr ? gtk_popover_get_child(popover) : nullptr;
|
||||||
static const char* const kExpected[] = {"Fit to data", "Layout editor", "Signal list",
|
// The popover's child is the stack the menu's two views are pages of; the rows are the visible
|
||||||
|
// page's. The Help view is measured by the help probe, not here.
|
||||||
|
if (box != nullptr && GTK_IS_STACK(box)) box = gtk_stack_get_visible_child(GTK_STACK(box));
|
||||||
|
static const char* const kExpected[] = {"Fit to data", "Layout editor",
|
||||||
"Export visible range...", "Configurations...",
|
"Export visible range...", "Configurations...",
|
||||||
"Keyboard shortcuts", "Help", "About udpscope"};
|
"Help", "Preferences\u2026", "About udpscope"};
|
||||||
const int expected = static_cast<int>(sizeof(kExpected) / sizeof(kExpected[0]));
|
const int expected = static_cast<int>(sizeof(kExpected) / sizeof(kExpected[0]));
|
||||||
int rows = 0;
|
int rows = 0;
|
||||||
int bad = 0;
|
int bad = 0;
|
||||||
@@ -4311,13 +4445,33 @@ UiShortcuts ui_shortcut_rows(const char* path) {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prints the manual launch's outcome, which arrives in a callback (the launch is asynchronous and
|
||||||
|
/// the viewer is another process) -- so the line is written from a timer a moment later rather than
|
||||||
|
/// from `help_probe`. `done=0` is a launch that never called back, which is a reading of its own.
|
||||||
|
gboolean help_launch_report(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
const std::string installed = help_installed_path();
|
||||||
|
std::fprintf(stderr, "[help] launch attempted=%d done=%d ok=%d error='%s' installed=%s\n",
|
||||||
|
a->dbg_help_launch_attempted ? 1 : 0, a->dbg_help_launch_done ? 1 : 0,
|
||||||
|
a->dbg_help_launch_ok ? 1 : 0, a->dbg_help_launch_error.c_str(),
|
||||||
|
installed.empty() ? "(not found)" : installed.c_str());
|
||||||
|
std::fflush(stderr);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
gboolean help_probe(gpointer user) {
|
gboolean help_probe(gpointer user) {
|
||||||
App* a = static_cast<App*>(user);
|
App* a = static_cast<App*>(user);
|
||||||
const HelpReport report = help_self_check();
|
const HelpReport report = help_self_check();
|
||||||
const std::string session =
|
const std::string session =
|
||||||
layout_path(a->model->config().server, a->model->config().port);
|
layout_path(a->model->config().server, a->model->config().port);
|
||||||
|
|
||||||
show_help(GTK_WINDOW(a->window));
|
// The manual is *launched* here and read no further: it opens 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
|
||||||
|
// read back or close. `help_self_check()` above is the assertion on the *set* being on disk where
|
||||||
|
// the build says it is; the launch is the assertion on the *path*, and its outcome is reported by
|
||||||
|
// `help_launch_report()` once the launch's callback has run. The failure alert the callback shows
|
||||||
|
// is deliberately left up -- it is what a user sees, and the probe must not report a failure as a
|
||||||
|
// silence. The two AdwDialogs below are still built, read and closed for real.
|
||||||
show_about(GTK_WINDOW(a->window), session);
|
show_about(GTK_WINDOW(a->window), session);
|
||||||
show_shortcuts(GTK_WINDOW(a->window));
|
show_shortcuts(GTK_WINDOW(a->window));
|
||||||
// Read while it is open: the icon is the point of the dialog carrying one, and
|
// Read while it is open: the icon is the point of the dialog carrying one, and
|
||||||
@@ -4325,6 +4479,18 @@ gboolean help_probe(gpointer user) {
|
|||||||
const std::string about_icon = about_icon_name();
|
const std::string about_icon = about_icon_name();
|
||||||
close_help_windows();
|
close_help_windows();
|
||||||
|
|
||||||
|
// The manual's path: on a successful launch no window is left behind, so this cannot be observed
|
||||||
|
// any other way; `show_help()`'s reporter is the only hook that runs on that path.
|
||||||
|
a->dbg_help_launch_attempted = true;
|
||||||
|
a->dbg_help_launch_done = false;
|
||||||
|
a->dbg_help_launch_ok = false;
|
||||||
|
a->dbg_help_launch_error.clear();
|
||||||
|
show_help(GTK_WINDOW(a->window), [a](bool ok, const std::string& error) {
|
||||||
|
a->dbg_help_launch_done = true;
|
||||||
|
a->dbg_help_launch_ok = ok;
|
||||||
|
a->dbg_help_launch_error = error;
|
||||||
|
});
|
||||||
|
|
||||||
int accel_count = 0;
|
int accel_count = 0;
|
||||||
int gesture_count = 0;
|
int gesture_count = 0;
|
||||||
(void)app_accels(&accel_count);
|
(void)app_accels(&accel_count);
|
||||||
@@ -4334,15 +4500,407 @@ gboolean help_probe(gpointer user) {
|
|||||||
// works and is not documented, or is documented and does nothing.
|
// works and is not documented, or is documented and does nothing.
|
||||||
const UiShortcuts ui = ui_shortcut_rows("/org/udpscope/ui/shortcuts.ui");
|
const UiShortcuts ui = ui_shortcut_rows("/org/udpscope/ui/shortcuts.ui");
|
||||||
std::fprintf(stderr,
|
std::fprintf(stderr,
|
||||||
"[help] pages=%d parsed=%d shortest='%s' %zu B; shortcuts: app=%d plot=%d tables=%d "
|
"[help] topics=%d index=%d figures=%d dir=%s; shortcuts: app=%d plot=%d tables=%d "
|
||||||
"ui=%d ui_accel=%d ui_icon=%d ui_bare=%d; "
|
"ui=%d ui_accel=%d ui_icon=%d ui_bare=%d; "
|
||||||
"about version=%s commit=%s built=%s settings=%s icon=%s; closed ok\n",
|
"about version=%s commit=%s built=%s settings=%s icon=%s; closed ok\n",
|
||||||
report.pages, report.parsed, report.shortest_title.c_str(),
|
report.topics, report.index ? 1 : 0, report.figures, UDPSCOPE_HELP_DIR,
|
||||||
report.shortest_bytes, accel_count, gesture_count, accel_count + gesture_count,
|
accel_count, gesture_count, accel_count + gesture_count,
|
||||||
ui.rows, ui.with_accel, ui.with_icon, ui.bare, build_info::kVersion,
|
ui.rows, ui.with_accel, ui.with_icon, ui.bare, build_info::kVersion,
|
||||||
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
|
build_info::kCommit, build_info::kTimestamp, config_dir().c_str(),
|
||||||
about_icon.c_str());
|
about_icon.c_str());
|
||||||
std::fflush(stderr);
|
std::fflush(stderr);
|
||||||
|
// The launch's outcome needs its callback to have run; a moment is enough for a local
|
||||||
|
// `g_app_info_launch_default_for_uri()`, and `done=0` in the line is how a launch that never
|
||||||
|
// called back reads.
|
||||||
|
g_timeout_add(600, help_launch_report, a);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UDPSCOPE_PROBE_HELPUI_MS`: the help affordance layer, end to end -- the first-run auto-start, the
|
||||||
|
/// four entries of the Help submenu, the contextual-help toggle and what it does to the "?"
|
||||||
|
/// affordances, and the tour started, stepped and dismissed.
|
||||||
|
///
|
||||||
|
/// The steps are 300 ms apart because everything it measures is an *allocation*: a widget's is 0 in
|
||||||
|
/// the tick it is first shown (see the allocation bullets in
|
||||||
|
/// `docs/claude/invariants-trigger-and-pinning.md`), so a step that read its own predecessor's
|
||||||
|
/// geometry would read a rectangle from before the frame that placed it.
|
||||||
|
///
|
||||||
|
/// The tour is started *from here* rather than from the window, so the probe path is exercised with
|
||||||
|
/// whatever history the run has; the auto-start is read first, before this touches anything.
|
||||||
|
void helpui_probe_next(App& a, int step, guint delay_ms) {
|
||||||
|
a.dbg_helpui_step = step;
|
||||||
|
g_timeout_add(delay_ms, helpui_probe_step, &a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The window's event controllers, and how many of them are clicks. A tour that grabs input and does
|
||||||
|
/// not give it back shows up here as a count that grew.
|
||||||
|
void helpui_count_controllers(App* a, int* total, int* clicks) {
|
||||||
|
*total = 0;
|
||||||
|
*clicks = 0;
|
||||||
|
if (a->window == nullptr) return;
|
||||||
|
GListModel* controllers = gtk_widget_observe_controllers(a->window);
|
||||||
|
const guint n = controllers != nullptr ? g_list_model_get_n_items(controllers) : 0;
|
||||||
|
for (guint i = 0; i < n; ++i) {
|
||||||
|
GObject* item = G_OBJECT(g_list_model_get_item(controllers, i));
|
||||||
|
if (item != nullptr) {
|
||||||
|
++*total;
|
||||||
|
if (GTK_IS_GESTURE_CLICK(item)) ++*clicks;
|
||||||
|
g_object_unref(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (controllers != nullptr) g_object_unref(controllers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The view the menu is showing: the stack's visible child, which is where the rows are.
|
||||||
|
GtkWidget* helpui_menu_view(App* a) {
|
||||||
|
if (a->menu_stack == nullptr) return nullptr;
|
||||||
|
return gtk_stack_get_visible_child(GTK_STACK(a->menu_stack));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line: the spotlight, the widget it spotlights, and the card, all in **window coordinates** --
|
||||||
|
/// the frame a screenshot is cropped in -- with the window's own record of the sidebar page beside
|
||||||
|
/// them, so a stop that had to change the page to find its target is readable as such. `contains` is
|
||||||
|
/// the spotlight covering its target (it is the target's own rectangle, grown three pixels) and
|
||||||
|
/// `clear` is the card missing the target. `probe` is the tag it is printed under, so a window with
|
||||||
|
/// more than one tour probe can tell its lines apart.
|
||||||
|
void tour_geometry_line(App& a, const char* probe, const char* what) {
|
||||||
|
graphene_rect_t spot = GRAPHENE_RECT_INIT(0, 0, 0, 0);
|
||||||
|
graphene_rect_t target = GRAPHENE_RECT_INIT(0, 0, 0, 0);
|
||||||
|
graphene_rect_t card = GRAPHENE_RECT_INIT(0, 0, 0, 0);
|
||||||
|
GtkWidget* t = tour_target(a);
|
||||||
|
const bool have_spot =
|
||||||
|
a.tour.spot != nullptr && gtk_widget_compute_bounds(a.tour.spot, a.window, &spot);
|
||||||
|
const bool have_target = t != nullptr && gtk_widget_get_mapped(t) &&
|
||||||
|
gtk_widget_compute_bounds(t, a.window, &target);
|
||||||
|
const bool have_card =
|
||||||
|
a.tour.card != nullptr && gtk_widget_compute_bounds(a.tour.card, a.window, &card);
|
||||||
|
const auto contains = [](const graphene_rect_t& outer, const graphene_rect_t& inner) {
|
||||||
|
return outer.origin.x <= inner.origin.x + 0.5 && outer.origin.y <= inner.origin.y + 0.5 &&
|
||||||
|
outer.origin.x + outer.size.width >= inner.origin.x + inner.size.width - 0.5 &&
|
||||||
|
outer.origin.y + outer.size.height >= inner.origin.y + inner.size.height - 0.5;
|
||||||
|
};
|
||||||
|
const auto overlaps = [](const graphene_rect_t& p, const graphene_rect_t& q) {
|
||||||
|
return p.origin.x < q.origin.x + q.size.width && q.origin.x < p.origin.x + p.size.width &&
|
||||||
|
p.origin.y < q.origin.y + q.size.height && q.origin.y < p.origin.y + p.size.height;
|
||||||
|
};
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[%s] %s=%d/%d visible=%d side_tab=%s spot=[%.0f,%.0f %.0fx%.0f] "
|
||||||
|
"target=[%.0f,%.0f %.0fx%.0f] contains=%d card=[%.0f,%.0f %.0fx%.0f] clear=%d\n",
|
||||||
|
probe, what, tour_step_index(a) + 1, tour_step_count(a), tour_active(a) ? 1 : 0,
|
||||||
|
a.side_tab.c_str(), spot.origin.x, spot.origin.y, spot.size.width, spot.size.height,
|
||||||
|
target.origin.x, target.origin.y, target.size.width, target.size.height,
|
||||||
|
have_spot && have_target && contains(spot, target) ? 1 : 0, card.origin.x,
|
||||||
|
card.origin.y, card.size.width, card.size.height,
|
||||||
|
have_card && have_target && !overlaps(card, target) ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean helpui_probe_step(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
const int step = a->dbg_helpui_step;
|
||||||
|
|
||||||
|
if (step == 0) {
|
||||||
|
// The run's own history decides the auto-start (`settings.tour_seen`), and this reads it before
|
||||||
|
// anything else touches the tour: a throwaway config directory is a first run, and a second run
|
||||||
|
// against the same one is not.
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[helpui] auto_started=%d visible=%d steps=%d step=%d (%s)\n",
|
||||||
|
a->tour_auto_started ? 1 : 0, tour_active(*a) ? 1 : 0, tour_step_count(*a),
|
||||||
|
tour_step_index(*a),
|
||||||
|
a->settings.tour_seen ? "tour_seen already set" : "first run");
|
||||||
|
if (tour_active(*a)) tour_dismiss(*a);
|
||||||
|
std::string ids;
|
||||||
|
const int hints = context_help_count(*a, &ids);
|
||||||
|
int hint_w = 0;
|
||||||
|
int hint_h = 0;
|
||||||
|
context_help_size(*a, &hint_w, &hint_h);
|
||||||
|
std::fprintf(stderr, "[helpui] contextual on: hints=%d ids={%s} hint=%dx%d\n", hints,
|
||||||
|
ids.c_str(), hint_w, hint_h);
|
||||||
|
int total = 0;
|
||||||
|
int clicks = 0;
|
||||||
|
helpui_count_controllers(a, &total, &clicks);
|
||||||
|
a->dbg_helpui_controllers = total;
|
||||||
|
// Autohide off *for the probe only*, the same as the menu probe: a popover dismisses itself when
|
||||||
|
// it loses the pointer grab, and under a bare `xvfb-run` there is no window manager to hand it
|
||||||
|
// one, so the rows would be measured before they were ever mapped.
|
||||||
|
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
|
||||||
|
if (popover != nullptr) gtk_popover_set_autohide(popover, FALSE);
|
||||||
|
gtk_menu_button_popup(GTK_MENU_BUTTON(a->menu_button));
|
||||||
|
helpui_probe_next(*a, 1, 300);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step == 1) {
|
||||||
|
// The menu's own view first: the door into the Help view has to be on it.
|
||||||
|
GtkWidget* box = helpui_menu_view(a);
|
||||||
|
int rows = 0;
|
||||||
|
int help_row = 0;
|
||||||
|
for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
|
||||||
|
row != nullptr; row = gtk_widget_get_next_sibling(row)) {
|
||||||
|
if (!GTK_IS_BUTTON(row)) continue;
|
||||||
|
GtkWidget* label = nullptr;
|
||||||
|
GtkWidget* accel = nullptr;
|
||||||
|
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
|
||||||
|
if (label != nullptr && std::strcmp(gtk_label_get_text(GTK_LABEL(label)), "Help") == 0) {
|
||||||
|
help_row = 1;
|
||||||
|
}
|
||||||
|
++rows;
|
||||||
|
}
|
||||||
|
std::fprintf(stderr, "[helpui] main view: rows=%d help_row=%d\n", rows, help_row);
|
||||||
|
// Into the Help view *through the row itself*: the handler is the thing that can break, so the
|
||||||
|
// probe goes the way a user goes.
|
||||||
|
if (a->menu_help_row != nullptr) g_signal_emit_by_name(a->menu_help_row, "clicked");
|
||||||
|
helpui_probe_next(*a, 2, 300);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step == 2) {
|
||||||
|
static const char* const kExpected[] = {"Back", "Keyboard shortcuts", "Documentation",
|
||||||
|
"Guided tour", "Enable contextual help"};
|
||||||
|
constexpr int kExpectedCount = static_cast<int>(sizeof(kExpected) / sizeof(kExpected[0]));
|
||||||
|
GtkWidget* box = helpui_menu_view(a);
|
||||||
|
int rows = 0;
|
||||||
|
int bad = 0;
|
||||||
|
for (GtkWidget* row = box != nullptr ? gtk_widget_get_first_child(box) : nullptr;
|
||||||
|
row != nullptr; row = gtk_widget_get_next_sibling(row)) {
|
||||||
|
if (!GTK_IS_BUTTON(row)) continue;
|
||||||
|
GtkWidget* label = nullptr;
|
||||||
|
GtkWidget* accel = nullptr;
|
||||||
|
menu_row_labels(gtk_widget_get_first_child(row), &label, &accel);
|
||||||
|
const char* text = label != nullptr ? gtk_label_get_text(GTK_LABEL(label)) : "";
|
||||||
|
const char* key = accel != nullptr ? gtk_label_get_text(GTK_LABEL(accel)) : "";
|
||||||
|
const bool named = rows < kExpectedCount && std::strcmp(text, kExpected[rows]) == 0;
|
||||||
|
if (!named) ++bad;
|
||||||
|
std::fprintf(stderr, "[helpui] entry %d '%s' accel='%s' order=%d\n", rows, text, key,
|
||||||
|
named ? 1 : 0);
|
||||||
|
++rows;
|
||||||
|
}
|
||||||
|
const bool on = a->contextual_toggle != nullptr &&
|
||||||
|
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(a->contextual_toggle)) != FALSE;
|
||||||
|
std::fprintf(stderr, "[helpui] help view: rows=%d expected=%d rows_bad=%d back=%d toggle=%d "
|
||||||
|
"entries='%s|%s|%s'\n",
|
||||||
|
rows, kExpectedCount, bad, rows > 0 ? 1 : 0, on ? 1 : 0,
|
||||||
|
a->menu_shortcuts_row != nullptr ? "Keyboard shortcuts" : "?",
|
||||||
|
a->menu_docs_row != nullptr ? "Documentation" : "?",
|
||||||
|
a->menu_tour_row != nullptr ? "Guided tour" : "?");
|
||||||
|
// The toggle's *effect*: off means the affordances are gone, not greyed. Both directions are
|
||||||
|
// driven through the row, because the row is what writes the setting.
|
||||||
|
if (a->contextual_toggle != nullptr) {
|
||||||
|
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->contextual_toggle), FALSE);
|
||||||
|
}
|
||||||
|
std::string off_ids;
|
||||||
|
const int off_hints = context_help_count(*a, &off_ids);
|
||||||
|
if (a->contextual_toggle != nullptr) {
|
||||||
|
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(a->contextual_toggle), TRUE);
|
||||||
|
}
|
||||||
|
std::string on_ids;
|
||||||
|
const int on_hints = context_help_count(*a, &on_ids);
|
||||||
|
std::fprintf(stderr, "[helpui] toggle: off -> hints=%d ids={%s}; on -> hints=%d ids={%s}; "
|
||||||
|
"setting=%s\n",
|
||||||
|
off_hints, off_ids.c_str(), on_hints, on_ids.c_str(),
|
||||||
|
a->settings.contextual_help ? "on" : "off");
|
||||||
|
GtkPopover* popover = gtk_menu_button_get_popover(GTK_MENU_BUTTON(a->menu_button));
|
||||||
|
if (popover != nullptr) gtk_popover_set_autohide(popover, TRUE);
|
||||||
|
gtk_menu_button_popdown(GTK_MENU_BUTTON(a->menu_button));
|
||||||
|
helpui_probe_next(*a, 3, 300);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step == 3) {
|
||||||
|
start_tour(*a);
|
||||||
|
std::fprintf(stderr, "[helpui] tour started from the probe: visible=%d steps=%d step=%d\n",
|
||||||
|
tour_active(*a) ? 1 : 0, tour_step_count(*a), tour_step_index(*a));
|
||||||
|
helpui_probe_next(*a, 4, 400);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step == 4 || step == 5) {
|
||||||
|
// The spotlight against the widget it spotlights, and the card against the same widget: the
|
||||||
|
// spotlight must *contain* the target, and the card must not overlap it when the window has room
|
||||||
|
// for a card beside or above/below it. All of it is an allocation, so it is read one frame after
|
||||||
|
// the step it describes: `update_tour()` places this frame from the allocation the last one left.
|
||||||
|
tour_geometry_line(*a, "helpui", "step");
|
||||||
|
if (step == 4) {
|
||||||
|
// Next, which is what a user presses to walk the tour.
|
||||||
|
tour_step(*a, 1);
|
||||||
|
std::fprintf(stderr, "[helpui] after Next: step=%d/%d visible=%d\n", tour_step_index(*a) + 1,
|
||||||
|
tour_step_count(*a), tour_active(*a) ? 1 : 0);
|
||||||
|
helpui_probe_next(*a, 5, 400);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
// Back, then Skip: the two ways out that are not the last step.
|
||||||
|
tour_step(*a, -1);
|
||||||
|
std::fprintf(stderr, "[helpui] after Back: step=%d/%d visible=%d\n", tour_step_index(*a) + 1,
|
||||||
|
tour_step_count(*a), tour_active(*a) ? 1 : 0);
|
||||||
|
tour_dismiss(*a);
|
||||||
|
// Dismissed, and nothing left behind. `controllers=` against the count taken before the tour
|
||||||
|
// opened is the reading for "no lingering input grab": this overlay is ordinary widgets and
|
||||||
|
// takes no grab at all, so a count that grew would be a grab somebody added.
|
||||||
|
int total = 0;
|
||||||
|
int clicks = 0;
|
||||||
|
helpui_count_controllers(a, &total, &clicks);
|
||||||
|
const auto hidden = [](GtkWidget* w) { return w == nullptr || !gtk_widget_is_visible(w); };
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[helpui] after Skip: visible=%d dims_hidden=%d spot_hidden=%d card_hidden=%d "
|
||||||
|
"controllers=%d (was %d) click_gestures=%d\n",
|
||||||
|
tour_active(*a) ? 1 : 0,
|
||||||
|
hidden(a->tour.dim[0]) && hidden(a->tour.dim[1]) && hidden(a->tour.dim[2]) &&
|
||||||
|
hidden(a->tour.dim[3])
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
hidden(a->tour.spot) ? 1 : 0, hidden(a->tour.card) ? 1 : 0, total,
|
||||||
|
a->dbg_helpui_controllers, clicks);
|
||||||
|
capture_help_state(*a);
|
||||||
|
std::fprintf(stderr, "[helpui] layer: %s\n", a->dbg_context_help.c_str());
|
||||||
|
helpui_probe_next(*a, 6, 300);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (step == 6 || step == 8) {
|
||||||
|
// The trigger pane's summary grid is on screen only in *locked* mode, and one of the help's
|
||||||
|
// figures is that content: the probe reads `help figures:` in both states, so the geometry agent
|
||||||
|
// has the locked pane's numbers without having to guess how to get there.
|
||||||
|
if (a->trigger_edit != nullptr) g_signal_emit_by_name(a->trigger_edit, "clicked");
|
||||||
|
helpui_probe_next(*a, step == 6 ? 7 : 9, 400);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7 (locked) and step 9 (back in configuration mode): one capture each, so each line is the
|
||||||
|
// state it names.
|
||||||
|
capture_help_state(*a);
|
||||||
|
std::fprintf(stderr, "[helpui] figures %s: %s\n", step == 7 ? "locked" : "config",
|
||||||
|
a->dbg_help_figures.c_str());
|
||||||
|
if (step == 7) {
|
||||||
|
helpui_probe_next(*a, 8, 400);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
// The search block's "(i)" rides the expander's *header* box, which is in the widget tree and on
|
||||||
|
// screen while the block is collapsed -- unlike the expander's child, which is out of the
|
||||||
|
// widget-parent chain until it is expanded (see the invariant). Read here, in config mode with the
|
||||||
|
// pane and the sidebar up and the block still collapsed: `mapped=1` and a real size *on a collapsed
|
||||||
|
// expander* is the whole point, and a hint hung on the child would read 0x0 here.
|
||||||
|
if (a->trigger_search != nullptr) {
|
||||||
|
GtkWidget* hint = gtk_widget_get_last_child(a->trigger_search);
|
||||||
|
int sw = 0;
|
||||||
|
int sh = 0;
|
||||||
|
context_help_size_in(hint, &sw, &sh);
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[helpui] collapsed search: expanded=%d mapped=%d mapped_header=%d hint=%dx%d\n",
|
||||||
|
a->search_exp != nullptr && gtk_expander_get_expanded(GTK_EXPANDER(a->search_exp))
|
||||||
|
? 1
|
||||||
|
: 0,
|
||||||
|
hint != nullptr && gtk_widget_get_mapped(hint) ? 1 : 0,
|
||||||
|
gtk_widget_get_mapped(a->trigger_search) ? 1 : 0, sw, sh);
|
||||||
|
}
|
||||||
|
std::fflush(stderr);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the tour's surface switch: a stop that stands on another stack page -------
|
||||||
|
|
||||||
|
/// Whether the walk's plan holds `target`. `steps=` alone cannot say a stop was *filtered out* rather
|
||||||
|
/// than merely reordered, so the two cases the filter tells apart are read against the plan itself.
|
||||||
|
bool tour_plan_holds(const App& a, GtkWidget* target) {
|
||||||
|
for (const TourStop& stop : a.tour.plan) {
|
||||||
|
if (stop.target == target) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean tour_surface_probe_step(gpointer user);
|
||||||
|
|
||||||
|
void tour_surface_probe_next(App& a, guint delay_ms) {
|
||||||
|
g_timeout_add(delay_ms, tour_surface_probe_step, &a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `UDPSCOPE_PROBE_TOUR_SURFACE_MS`: the stop that stands on a page it is not on. It puts the sidebar
|
||||||
|
/// on **Measure**, starts the window's tour, and walks it a stop per tick -- a tick apart on purpose,
|
||||||
|
/// because a stack's new page is allocated on the frame *after* the switch, so a reading taken in the
|
||||||
|
/// switch's own tick would be of the page the walk has just left (and `update_tour()` places the hole
|
||||||
|
/// from the frame that *has* been laid out). Every step prints the sidebar's page and whether the
|
||||||
|
/// stop's target is mapped, and the stop whose target is the signal list prints the same geometry
|
||||||
|
/// `[helpui]` does: **`side_tab=signals` with `target=signal_list` and `contains=1`** is the reading
|
||||||
|
/// that the stop was not merely kept but *made* visible. It then puts the sidebar away --
|
||||||
|
/// `sidebar_wanted` is the wish the tick re-asserts -- and starts the tour again: a *collapsed* pane
|
||||||
|
/// leaves the stack unmapped, so the stops that need it are dropped and `steps=` falls. That pair is
|
||||||
|
/// the filter's two cases, read rather than assumed.
|
||||||
|
gboolean tour_surface_probe_step(gpointer user) {
|
||||||
|
App* a = static_cast<App*>(user);
|
||||||
|
if (a->window == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
const auto mapped = [](GtkWidget* w) { return w != nullptr && gtk_widget_get_mapped(w) ? 1 : 0; };
|
||||||
|
switch (a->dbg_tour_surface_step) {
|
||||||
|
case 0: {
|
||||||
|
// Where the run starts: a fresh config's sidebar is on Signals, so this is a *first* run's
|
||||||
|
// state. The auto-start's walk (if one began) would only confuse the reading; it goes first.
|
||||||
|
std::fprintf(stderr, "[toursurface] before: side_tab=%s signal_list_mapped=%d running=%d\n",
|
||||||
|
a->side_tab.c_str(), mapped(a->signal_list), tour_active(*a) ? 1 : 0);
|
||||||
|
tour_dismiss(*a);
|
||||||
|
select_side_tab(*a, "measure");
|
||||||
|
a->dbg_tour_surface_step = 1;
|
||||||
|
tour_surface_probe_next(*a, 500);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
case 1: {
|
||||||
|
// A frame after the switch, so the Measure page has been laid out and the list really is off
|
||||||
|
// screen. The tour is started *here*, from this state.
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[toursurface] on measure: side_tab=%s signal_list_mapped=%d side_stack_mapped=%d\n",
|
||||||
|
a->side_tab.c_str(), mapped(a->signal_list), mapped(a->side_stack));
|
||||||
|
start_tour(*a);
|
||||||
|
std::fprintf(stderr, "[toursurface] tour: visible=%d steps=%d step=%d signals_stop=%d\n",
|
||||||
|
tour_active(*a) ? 1 : 0, tour_step_count(*a), tour_step_index(*a),
|
||||||
|
tour_plan_holds(*a, a->signal_list) ? 1 : 0);
|
||||||
|
a->dbg_tour_surface_step = 2;
|
||||||
|
tour_surface_probe_next(*a, 400);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
case 2: {
|
||||||
|
if (!tour_active(*a)) {
|
||||||
|
std::fprintf(stderr, "[toursurface] walk ended before the signals stop\n");
|
||||||
|
std::fflush(stderr);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
GtkWidget* target = tour_target(*a);
|
||||||
|
const bool is_list = target != nullptr && target == a->signal_list;
|
||||||
|
std::fprintf(stderr, "[toursurface] step=%d/%d side_tab=%s target_mapped=%d%s\n",
|
||||||
|
tour_step_index(*a) + 1, tour_step_count(*a), a->side_tab.c_str(), mapped(target),
|
||||||
|
is_list ? " target=signal_list" : "");
|
||||||
|
if (is_list) {
|
||||||
|
// The frame after the switch: the page has been laid out and `update_tour()` has placed the
|
||||||
|
// hole from it, so this is the reading that the spotlight is on the list.
|
||||||
|
tour_geometry_line(*a, "toursurface", "signals stop");
|
||||||
|
tour_dismiss(*a);
|
||||||
|
a->dbg_tour_surface_step = 3;
|
||||||
|
tour_surface_probe_next(*a, 400);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
tour_step(*a, 1);
|
||||||
|
tour_surface_probe_next(*a, 400);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
case 3: {
|
||||||
|
// The other half of the filter. `sidebar_wanted` is the user's wish that the tick re-asserts
|
||||||
|
// (`update_side_visibility`), so setting it here is how a probe puts the sidebar away for good;
|
||||||
|
// the delay leaves the split view time to animate the column shut and unmap it.
|
||||||
|
a->sidebar_wanted = false;
|
||||||
|
a->dbg_tour_surface_step = 4;
|
||||||
|
tour_surface_probe_next(*a, 700);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
start_tour(*a);
|
||||||
|
std::fprintf(stderr,
|
||||||
|
"[toursurface] collapsed: side_stack_mapped=%d steps=%d signals_stop=%d "
|
||||||
|
"side_stop=%d\n",
|
||||||
|
mapped(a->side_stack), tour_step_count(*a),
|
||||||
|
tour_plan_holds(*a, a->signal_list) ? 1 : 0,
|
||||||
|
tour_plan_holds(*a, a->side_box) ? 1 : 0);
|
||||||
|
tour_dismiss(*a);
|
||||||
|
a->sidebar_wanted = true;
|
||||||
|
std::fflush(stderr);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
}
|
||||||
return G_SOURCE_REMOVE;
|
return G_SOURCE_REMOVE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+45
-11
@@ -166,7 +166,7 @@ int math_index_for_signal(App& app, const std::string& name) {
|
|||||||
/// The `MATH` boundary in the one channel list, carrying the `+` that creates a channel. Not
|
/// The `MATH` boundary in the one channel list, carrying the `+` that creates a channel. Not
|
||||||
/// selectable and not activatable: it is a heading, and a heading that highlights under the
|
/// selectable and not activatable: it is a heading, and a heading that highlights under the
|
||||||
/// pointer reads as a row you can plot.
|
/// pointer reads as a row you can plot.
|
||||||
GtkWidget* make_math_divider(App& app) {
|
GtkWidget* make_math_divider(App& app, bool with_add) {
|
||||||
GtkWidget* row = gtk_list_box_row_new();
|
GtkWidget* row = gtk_list_box_row_new();
|
||||||
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(row), FALSE);
|
gtk_list_box_row_set_selectable(GTK_LIST_BOX_ROW(row), FALSE);
|
||||||
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), FALSE);
|
gtk_list_box_row_set_activatable(GTK_LIST_BOX_ROW(row), FALSE);
|
||||||
@@ -180,11 +180,16 @@ GtkWidget* make_math_divider(App& app) {
|
|||||||
gtk_widget_set_halign(label, GTK_ALIGN_START);
|
gtk_widget_set_halign(label, GTK_ALIGN_START);
|
||||||
gtk_widget_set_hexpand(label, TRUE);
|
gtk_widget_set_hexpand(label, TRUE);
|
||||||
gtk_box_append(GTK_BOX(box), label);
|
gtk_box_append(GTK_BOX(box), label);
|
||||||
GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic");
|
// Locked: the heading stays, because the list it divides is the one being read, but the `+` that
|
||||||
gtk_widget_add_css_class(add, "flat");
|
// creates a channel does not. A `+` on a locked list is a button that opens the editor the lock
|
||||||
gtk_widget_set_tooltip_text(add, "add a math channel");
|
// exists to keep shut.
|
||||||
g_signal_connect(add, "clicked", G_CALLBACK(on_math_add_clicked), &app);
|
if (with_add) {
|
||||||
gtk_box_append(GTK_BOX(box), add);
|
GtkWidget* add = gtk_button_new_from_icon_name("list-add-symbolic");
|
||||||
|
gtk_widget_add_css_class(add, "flat");
|
||||||
|
gtk_widget_set_tooltip_text(add, "add a math channel");
|
||||||
|
g_signal_connect(add, "clicked", G_CALLBACK(on_math_add_clicked), &app);
|
||||||
|
gtk_box_append(GTK_BOX(box), add);
|
||||||
|
}
|
||||||
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -278,6 +283,9 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
|
|||||||
std::size_t plain = 0;
|
std::size_t plain = 0;
|
||||||
for (std::size_t i = 0; i < signals.size(); ++i) {
|
for (std::size_t i = 0; i < signals.size(); ++i) {
|
||||||
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable
|
if (ScopeModel::is_time_signal(signals, i)) continue; // the time base is not plottable
|
||||||
|
// Locked: a row per signal that is *on*, which is the list a user reads rather than the list they
|
||||||
|
// configure. Turning one on is what the pane's [edit] button is for.
|
||||||
|
if (app.signals_locked && !app.model->signal_enabled(i)) continue;
|
||||||
const std::string base = layer_base_name(signals[i].name);
|
const std::string base = layer_base_name(signals[i].name);
|
||||||
bool seen = false;
|
bool seen = false;
|
||||||
for (std::size_t j = 0; j < i && !seen; ++j) {
|
for (std::size_t j = 0; j < i && !seen; ++j) {
|
||||||
@@ -293,7 +301,7 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
|
|||||||
// `+` that creates the first one.
|
// `+` that creates the first one.
|
||||||
if (sig.math && !math_divider_done) {
|
if (sig.math && !math_divider_done) {
|
||||||
math_divider_done = true;
|
math_divider_done = true;
|
||||||
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app));
|
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app, !app.signals_locked));
|
||||||
}
|
}
|
||||||
if (!sig.math) ++plain;
|
if (!sig.math) ++plain;
|
||||||
GtkWidget* row = gtk_list_box_row_new();
|
GtkWidget* row = gtk_list_box_row_new();
|
||||||
@@ -309,7 +317,7 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
|
|||||||
chip, !app.model->signal_enabled(i) ? "not acquired"
|
chip, !app.model->signal_enabled(i) ? "not acquired"
|
||||||
: plotted ? "the colour this signal is plotted in -- click to change it"
|
: plotted ? "the colour this signal is plotted in -- click to change it"
|
||||||
: "on no plot, so there is no trace to colour");
|
: "on no plot, so there is no trace to colour");
|
||||||
if (plotted) {
|
if (plotted && !app.signals_locked) {
|
||||||
// A gesture on the label rather than a `GtkButton` around it: a button brings its own
|
// A gesture on the label rather than a `GtkButton` around it: a button brings its own
|
||||||
// padding, and padding on this row is what made the list twice as tall once already (see
|
// padding, and padding on this row is what made the list twice as tall once already (see
|
||||||
// this function's note). The chip also has to stay exactly one disc wide, or the names
|
// this function's note). The chip also has to stay exactly one disc wide, or the names
|
||||||
@@ -343,7 +351,9 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
|
|||||||
gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty());
|
gtk_widget_set_visible(enable, sig.math || app.waveform_name.empty());
|
||||||
g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(i));
|
g_object_set_data(G_OBJECT(enable), "udpscope-signal", GSIZE_TO_POINTER(i));
|
||||||
g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app);
|
g_signal_connect(enable, "notify::active", G_CALLBACK(on_signal_switch), &app);
|
||||||
gtk_box_append(GTK_BOX(box), enable);
|
// Locked: the switch decides what is acquired, so it is a configuration control and it goes with
|
||||||
|
// the rest of them.
|
||||||
|
if (!app.signals_locked) gtk_box_append(GTK_BOX(box), enable);
|
||||||
|
|
||||||
GtkWidget* name = gtk_label_new(sig.name.c_str());
|
GtkWidget* name = gtk_label_new(sig.name.c_str());
|
||||||
gtk_widget_set_hexpand(name, true);
|
gtk_widget_set_hexpand(name, true);
|
||||||
@@ -435,7 +445,9 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
|
|||||||
gtk_menu_button_set_popover(GTK_MENU_BUTTON(more), popover);
|
gtk_menu_button_set_popover(GTK_MENU_BUTTON(more), popover);
|
||||||
gtk_widget_add_css_class(more, "flat");
|
gtk_widget_add_css_class(more, "flat");
|
||||||
gtk_widget_set_tooltip_text(more, "information and calibration");
|
gtk_widget_set_tooltip_text(more, "information and calibration");
|
||||||
gtk_box_append(GTK_BOX(box), more);
|
// Locked: the menu holds calibration, deletion and a rename, which are exactly what this mode is
|
||||||
|
// not for. The popover is left unattached, so nothing can reach it.
|
||||||
|
if (!app.signals_locked) gtk_box_append(GTK_BOX(box), more);
|
||||||
|
|
||||||
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
|
gtk_list_box_row_set_child(GTK_LIST_BOX_ROW(row), box);
|
||||||
|
|
||||||
@@ -445,13 +457,26 @@ void rebuild_signal_list(App& app, const std::vector<SignalInfo>& signals) {
|
|||||||
g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx,
|
g_signal_connect_data(source, "prepare", G_CALLBACK(on_drag_prepare), ctx,
|
||||||
[](gpointer data, GClosure*) { delete static_cast<DragContext*>(data); },
|
[](gpointer data, GClosure*) { delete static_cast<DragContext*>(data); },
|
||||||
G_CONNECT_DEFAULT);
|
G_CONNECT_DEFAULT);
|
||||||
|
// A drag out of an *overlaid* sidebar would have nowhere to land (see `row_drag_sidebar()`), so the
|
||||||
|
// sidebar steps out of the way for as long as the drag lasts.
|
||||||
|
g_signal_connect(source, "drag-begin", G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gpointer user) {
|
||||||
|
row_drag_sidebar(*static_cast<App*>(user), true);
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
|
g_signal_connect(source, "drag-end",
|
||||||
|
G_CALLBACK(+[](GtkDragSource*, GdkDrag*, gboolean, gpointer user) {
|
||||||
|
row_drag_sidebar(*static_cast<App*>(user), false);
|
||||||
|
}),
|
||||||
|
&app);
|
||||||
gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source));
|
gtk_widget_add_controller(row, GTK_EVENT_CONTROLLER(source));
|
||||||
|
|
||||||
gtk_list_box_append(GTK_LIST_BOX(list), row);
|
gtk_list_box_append(GTK_LIST_BOX(list), row);
|
||||||
}
|
}
|
||||||
// Appended when there are no math channels at all: the `+` is the only way to create the first
|
// Appended when there are no math channels at all: the `+` is the only way to create the first
|
||||||
// one, so it cannot be conditional on one already existing.
|
// one, so it cannot be conditional on one already existing.
|
||||||
if (!math_divider_done) gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app));
|
if (!math_divider_done) {
|
||||||
|
gtk_list_box_append(GTK_LIST_BOX(list), make_math_divider(app, !app.signals_locked));
|
||||||
|
}
|
||||||
app.dbg_signal_plain = plain;
|
app.dbg_signal_plain = plain;
|
||||||
app.dbg_signal_rows = 0;
|
app.dbg_signal_rows = 0;
|
||||||
for (GtkWidget* c = gtk_widget_get_first_child(list); c != nullptr;
|
for (GtkWidget* c = gtk_widget_get_first_child(list); c != nullptr;
|
||||||
@@ -567,6 +592,15 @@ GtkWidget* build_signal_panel(App& app) {
|
|||||||
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/signals-panel.ui");
|
GtkBuilder* ui = ui_load(app, "/org/udpscope/ui/signals-panel.ui");
|
||||||
GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "signal_panel"));
|
GtkWidget* box = GTK_WIDGET(gtk_builder_get_object(ui, "signal_panel"));
|
||||||
app.signal_list = GTK_WIDGET(gtk_builder_get_object(ui, "signal_list"));
|
app.signal_list = GTK_WIDGET(gtk_builder_get_object(ui, "signal_list"));
|
||||||
|
// The heading row, where the contextual help's "?" joins (see `ui_context_help.cpp`).
|
||||||
|
app.signals_head = GTK_WIDGET(gtk_builder_get_object(ui, "signals_head"));
|
||||||
|
// The hint line under it, which is where the "signals_config" hint joins: the heading already
|
||||||
|
// carries one, and the rule the layer follows is a hint beside the thing it explains.
|
||||||
|
app.signals_note = GTK_WIDGET(gtk_builder_get_object(ui, "signals_note"));
|
||||||
|
app.signals_edit = GTK_WIDGET(gtk_builder_get_object(ui, "signals_edit"));
|
||||||
|
if (app.signals_edit != nullptr) {
|
||||||
|
g_signal_connect(app.signals_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app);
|
||||||
|
}
|
||||||
if (box == nullptr || app.signal_list == nullptr) {
|
if (box == nullptr || app.signal_list == nullptr) {
|
||||||
// A missing id is a mistake in the `.ui`: the panel comes up empty rather than taking the window with
|
// A missing id is a mistake in the `.ui`: the panel comes up empty rather than taking the window with
|
||||||
// it, and `[udpscope] signals panel:` reads the difference.
|
// it, and `[udpscope] signals panel:` reads the difference.
|
||||||
|
|||||||
+550
@@ -0,0 +1,550 @@
|
|||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
// SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
//
|
||||||
|
// The guided tour: a walk over the surfaces a first run has no way of knowing about, drawn on a
|
||||||
|
// `GtkOverlay` inserted above a window's content.
|
||||||
|
//
|
||||||
|
// Why an overlay and not a popover per step: a popover takes a pointer grab, and a run of them takes
|
||||||
|
// one per step. The overlay takes none at all -- what makes the tour modal is the four dim
|
||||||
|
// rectangles, which are ordinary widgets that swallow the clicks that land on them, and dismissing
|
||||||
|
// the tour is hiding them. There is no grab to leak and nothing to unparent.
|
||||||
|
//
|
||||||
|
// The spotlight is *four* rectangles and not a drawing: the top, bottom, left and right strips
|
||||||
|
// around the target, positioned by margins each frame from `gtk_widget_compute_bounds()`. That is
|
||||||
|
// the whole of "dims everything except the target", it needs no cairo and no paint function, and it
|
||||||
|
// is re-measured on every frame -- the target's own allocation is 0 on the frame it is first shown
|
||||||
|
// (see the allocation bullets in `docs/claude/invariants-trigger-and-pinning.md`), and a window
|
||||||
|
// resize moves it under a spotlight that a one-shot measurement would leave behind.
|
||||||
|
//
|
||||||
|
// There are *two* tours and one implementation. The main window's walks the plot, the side panel,
|
||||||
|
// the trigger and the like; the source chooser's -- which appears *before* `App` exists -- walks the
|
||||||
|
// chooser's own widgets and then hands the tour on to the window through `Config::tour_pending`. The
|
||||||
|
// machinery below is written against a `TourView` and a list of `TourStop`s, so the difference
|
||||||
|
// between the two is only which widgets they name: the chooser keeps its table in
|
||||||
|
// `source_dialog.cpp`, where its widgets live.
|
||||||
|
|
||||||
|
#include "ui_app.hpp"
|
||||||
|
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <utility>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace udpscope {
|
||||||
|
namespace ui_impl {
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// The card's width. Wide enough for a comfortable line of the paragraph, narrow enough to fit
|
||||||
|
/// beside the plot on the documented window -- at 756 px the side panel is 320 and a card wider than
|
||||||
|
/// that is a card that has to cover its own target. It is a *floor* on the box, not a cap on the
|
||||||
|
/// paragraph: the wrapped label is capped by `max_width_chars` so the natural width never exceeds it.
|
||||||
|
constexpr int kCardWidth = 280;
|
||||||
|
/// The gap between the hole and whatever is placed beside it. Eight and not twelve: the side panel
|
||||||
|
/// is 320 px, and it has to hold the card and both gaps.
|
||||||
|
constexpr int kGap = 8;
|
||||||
|
|
||||||
|
/// Places an overlay child of the tour overlay at an exact rectangle. `GtkOverlay` positions an
|
||||||
|
/// overlay child by its alignment and its margins and gives it its measured size, so a fixed
|
||||||
|
/// rectangle is a size request plus two margins.
|
||||||
|
void place(GtkWidget* widget, int x, int y, int width, int height) {
|
||||||
|
gtk_widget_set_halign(widget, GTK_ALIGN_START);
|
||||||
|
gtk_widget_set_valign(widget, GTK_ALIGN_START);
|
||||||
|
gtk_widget_set_margin_start(widget, std::max(0, x));
|
||||||
|
gtk_widget_set_margin_top(widget, std::max(0, y));
|
||||||
|
gtk_widget_set_size_request(widget, std::max(0, width), std::max(0, height));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Places an overlay child by its *natural* size at a point. The card sizes itself -- its paragraph
|
||||||
|
/// wraps at a stated width -- and only its position is ours. Giving it the width we measured back as
|
||||||
|
/// a size request is a feedback loop: a request is a floor on the widget's own measure, so the width
|
||||||
|
/// it was placed at becomes the width it reports next frame, and a starting value that is one pixel
|
||||||
|
/// too large grows by that pixel for ever.
|
||||||
|
void place_natural(GtkWidget* widget, int x, int y) {
|
||||||
|
gtk_widget_set_halign(widget, GTK_ALIGN_START);
|
||||||
|
gtk_widget_set_valign(widget, GTK_ALIGN_START);
|
||||||
|
gtk_widget_set_margin_start(widget, std::max(0, x));
|
||||||
|
gtk_widget_set_margin_top(widget, std::max(0, y));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stop currently walked, or nullptr when the walk is not on a stop.
|
||||||
|
const TourStop* current_stop(const TourView& view) {
|
||||||
|
if (view.index < 0 || view.index >= static_cast<int>(view.plan.size())) return nullptr;
|
||||||
|
return &view.plan[static_cast<std::size_t>(view.index)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects a stop's page on the view's stack, before anything is measured against the target. Both
|
||||||
|
/// windows' stacks are `AdwViewStack`s and their page names are the ones the same tables name their
|
||||||
|
/// `.ui` pages with, so the machinery can do this itself; the `get_child_by_name()` guard keeps a
|
||||||
|
/// stop that names a page a later `.ui` dropped from blanking the stack. The stack's own
|
||||||
|
/// `notify::visible-child-name` is what tells the window which page it is now on.
|
||||||
|
void select_view_page(TourView& view, const char* page) {
|
||||||
|
if (view.page_stack == nullptr || page == nullptr) return;
|
||||||
|
if (adw_view_stack_get_child_by_name(ADW_VIEW_STACK(view.page_stack), page) == nullptr) return;
|
||||||
|
adw_view_stack_set_visible_child_name(ADW_VIEW_STACK(view.page_stack), page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a stop belongs in the walk. `get_mapped()`, not `is_visible()`: a stack hides its other
|
||||||
|
/// pages, and the split view its sidebar, with `gtk_widget_set_child_visible()`, which leaves the
|
||||||
|
/// widget's own visible flag -- the one `is_visible()` walks up the parents -- **true**. So the only
|
||||||
|
/// honest "is this on screen" test is whether it is mapped, and it is the one that keeps the filter's
|
||||||
|
/// two cases apart:
|
||||||
|
/// * a target not mapped because it is on *another page* is walked over when the stop names that
|
||||||
|
/// page and the stack itself is on screen -- selecting the page is what puts the target there;
|
||||||
|
/// * a target not mapped because its *pane is collapsed* leaves the stack unmapped too, so no page
|
||||||
|
/// can reveal it and the stop is still dropped.
|
||||||
|
bool stop_is_walkable(const TourView& view, const TourStop& stop) {
|
||||||
|
if (stop.target == nullptr) return false;
|
||||||
|
if (gtk_widget_get_mapped(stop.target)) return true;
|
||||||
|
return stop.page != nullptr && view.page_stack != nullptr &&
|
||||||
|
gtk_widget_get_mapped(view.page_stack);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Restates the card's text for the current stop. Called on a change of step, not per frame.
|
||||||
|
void show_step(TourView& view) {
|
||||||
|
const TourStop* stop = current_stop(view);
|
||||||
|
if (stop == nullptr || view.card == nullptr) return;
|
||||||
|
// The surface first, and nothing is measured here: the stop may live on a page the user has left,
|
||||||
|
// and a stack's new page is allocated on the frame *after* the switch, so a one-shot measure in
|
||||||
|
// this tick would read the page the walk has just left. `tour_update()` runs every frame and fills
|
||||||
|
// the hole in on the next one.
|
||||||
|
select_view_page(view, stop->page);
|
||||||
|
// The hole belongs to the step: reusing the last one for a frame would put the spotlight on the
|
||||||
|
// surface the tour has just left, and the next frame fills this one in.
|
||||||
|
view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0;
|
||||||
|
char markup[128];
|
||||||
|
std::snprintf(markup, sizeof(markup), "<b>%s</b>", stop->title);
|
||||||
|
gtk_label_set_markup(GTK_LABEL(view.title), markup);
|
||||||
|
gtk_label_set_text(GTK_LABEL(view.body), stop->body);
|
||||||
|
char counter[64];
|
||||||
|
std::snprintf(counter, sizeof(counter), "%d of %d", view.index + 1,
|
||||||
|
static_cast<int>(view.plan.size()));
|
||||||
|
gtk_label_set_text(GTK_LABEL(view.counter), counter);
|
||||||
|
gtk_widget_set_visible(view.back, view.index > 0);
|
||||||
|
gtk_button_set_label(GTK_BUTTON(view.next),
|
||||||
|
view.index + 1 >= static_cast<int>(view.plan.size()) ? "Finish" : "Next");
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
// --- the machinery, shared by both windows ----------------------------------
|
||||||
|
|
||||||
|
void tour_build(TourView& view, GtkWidget* overlay) {
|
||||||
|
if (overlay == nullptr || view.card != nullptr) return;
|
||||||
|
view.overlay = overlay;
|
||||||
|
for (GtkWidget*& dim : view.dim) {
|
||||||
|
dim = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
|
gtk_widget_add_css_class(dim, "udpscope-tour-dim");
|
||||||
|
gtk_overlay_add_overlay(GTK_OVERLAY(overlay), dim);
|
||||||
|
}
|
||||||
|
view.spot = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
|
gtk_widget_add_css_class(view.spot, "udpscope-tour-spot");
|
||||||
|
// The outline is a frame, not a shield: a click on the *target* is a click on the target.
|
||||||
|
gtk_widget_set_can_target(view.spot, FALSE);
|
||||||
|
gtk_overlay_add_overlay(GTK_OVERLAY(overlay), view.spot);
|
||||||
|
|
||||||
|
// The card, last: overlay children are painted in the order they were added, and the card has to
|
||||||
|
// be above the dim strips that cover the rest of the window.
|
||||||
|
view.card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
|
||||||
|
gtk_widget_add_css_class(view.card, "udpscope-tour-card");
|
||||||
|
gtk_widget_set_size_request(view.card, kCardWidth, -1);
|
||||||
|
|
||||||
|
view.title = gtk_label_new(nullptr);
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(view.title), 0.0f);
|
||||||
|
gtk_box_append(GTK_BOX(view.card), view.title);
|
||||||
|
|
||||||
|
view.body = gtk_label_new(nullptr);
|
||||||
|
gtk_label_set_wrap(GTK_LABEL(view.body), TRUE);
|
||||||
|
// The cap that keeps the card narrow. `max_width_chars` is what bounds a wrapped label's *natural*
|
||||||
|
// width; without it the paragraph's natural width is the whole sentence on one line and the card
|
||||||
|
// is that wide however it is placed.
|
||||||
|
gtk_label_set_max_width_chars(GTK_LABEL(view.body), 30);
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(view.body), 0.0f);
|
||||||
|
gtk_box_append(GTK_BOX(view.card), view.body);
|
||||||
|
|
||||||
|
GtkWidget* footer = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
|
||||||
|
view.counter = gtk_label_new(nullptr);
|
||||||
|
gtk_widget_add_css_class(view.counter, "dim-label");
|
||||||
|
gtk_label_set_xalign(GTK_LABEL(view.counter), 0.0f);
|
||||||
|
gtk_widget_set_hexpand(view.counter, TRUE);
|
||||||
|
gtk_box_append(GTK_BOX(footer), view.counter);
|
||||||
|
view.back = gtk_button_new_with_label("Back");
|
||||||
|
g_signal_connect(view.back, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
|
tour_advance(*static_cast<TourView*>(user), -1);
|
||||||
|
}),
|
||||||
|
&view);
|
||||||
|
gtk_box_append(GTK_BOX(footer), view.back);
|
||||||
|
GtkWidget* skip = gtk_button_new_with_label("Skip");
|
||||||
|
g_signal_connect(skip, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
|
tour_end(*static_cast<TourView*>(user));
|
||||||
|
}),
|
||||||
|
&view);
|
||||||
|
gtk_box_append(GTK_BOX(footer), skip);
|
||||||
|
view.next = gtk_button_new_with_label("Next");
|
||||||
|
gtk_widget_add_css_class(view.next, "suggested-action");
|
||||||
|
g_signal_connect(view.next, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
|
tour_advance(*static_cast<TourView*>(user), 1);
|
||||||
|
}),
|
||||||
|
&view);
|
||||||
|
gtk_box_append(GTK_BOX(footer), view.next);
|
||||||
|
gtk_box_append(GTK_BOX(view.card), footer);
|
||||||
|
gtk_overlay_add_overlay(GTK_OVERLAY(overlay), view.card);
|
||||||
|
|
||||||
|
// Everything starts hidden: the tour is not on screen until it is started.
|
||||||
|
for (GtkWidget* dim : view.dim) gtk_widget_set_visible(dim, FALSE);
|
||||||
|
gtk_widget_set_visible(view.spot, FALSE);
|
||||||
|
gtk_widget_set_visible(view.card, FALSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
void tour_begin(TourView& view, std::vector<TourStop> stops, void (*on_done)(void* user),
|
||||||
|
void* done_user) {
|
||||||
|
if (view.overlay == nullptr || view.card == nullptr) return;
|
||||||
|
// The walk is the stops whose surface is on screen *or* can be selected into view, filtered here
|
||||||
|
// rather than by each caller so the two windows cannot drift into two rules -- and because the
|
||||||
|
// rule needs the view's own `page_stack`, which the caller's table does not carry. See
|
||||||
|
// `stop_is_walkable()` for what separates a wrong page from a collapsed pane.
|
||||||
|
std::vector<TourStop> plan;
|
||||||
|
for (const TourStop& stop : stops) {
|
||||||
|
if (stop_is_walkable(view, stop)) plan.push_back(stop);
|
||||||
|
}
|
||||||
|
if (plan.empty()) return;
|
||||||
|
view.plan = std::move(plan);
|
||||||
|
view.on_done = on_done;
|
||||||
|
view.done_user = done_user;
|
||||||
|
view.index = 0;
|
||||||
|
view.running = true;
|
||||||
|
for (GtkWidget* dim : view.dim) gtk_widget_set_visible(dim, TRUE);
|
||||||
|
gtk_widget_set_visible(view.spot, TRUE);
|
||||||
|
gtk_widget_set_visible(view.card, TRUE);
|
||||||
|
show_step(view);
|
||||||
|
tour_update(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
void tour_advance(TourView& view, int delta) {
|
||||||
|
if (!view.running) return;
|
||||||
|
const int next = view.index + delta;
|
||||||
|
if (next < 0 || next >= static_cast<int>(view.plan.size())) {
|
||||||
|
// Past the last stop: this is the walk *finishing*, which is the one ending that hands the tour
|
||||||
|
// on. Take the callback out before `tour_end()` clears it.
|
||||||
|
void (*done)(void*) = view.on_done;
|
||||||
|
void* user = view.done_user;
|
||||||
|
tour_end(view);
|
||||||
|
if (done != nullptr) done(user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
view.index = next;
|
||||||
|
show_step(view);
|
||||||
|
tour_update(view);
|
||||||
|
}
|
||||||
|
|
||||||
|
void tour_end(TourView& view) {
|
||||||
|
view.running = false;
|
||||||
|
view.index = 0;
|
||||||
|
view.plan.clear();
|
||||||
|
view.on_done = nullptr;
|
||||||
|
view.done_user = nullptr;
|
||||||
|
for (GtkWidget* dim : view.dim) {
|
||||||
|
if (dim != nullptr) gtk_widget_set_visible(dim, FALSE);
|
||||||
|
}
|
||||||
|
if (view.spot != nullptr) gtk_widget_set_visible(view.spot, FALSE);
|
||||||
|
if (view.card != nullptr) gtk_widget_set_visible(view.card, FALSE);
|
||||||
|
}
|
||||||
|
|
||||||
|
GtkWidget* tour_target(TourView& view) {
|
||||||
|
const TourStop* stop = current_stop(view);
|
||||||
|
return stop != nullptr ? stop->target : nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void tour_update(TourView& view) {
|
||||||
|
if (!view.running || view.overlay == nullptr) return;
|
||||||
|
const int w = gtk_widget_get_width(view.overlay);
|
||||||
|
const int h = gtk_widget_get_height(view.overlay);
|
||||||
|
if (w <= 0 || h <= 0) return; // no allocation yet: nothing to place anything against
|
||||||
|
|
||||||
|
// The target, re-measured every frame. Its allocation is 0 on the frame it is first shown, so a
|
||||||
|
// one-shot measurement would leave the hole in the corner.
|
||||||
|
GtkWidget* target = tour_target(view);
|
||||||
|
graphene_rect_t bounds = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
|
||||||
|
// Mapped, not `is_visible()`: a widget on another page of a stack, or in a collapsed pane, keeps
|
||||||
|
// its own visible flag -- a stack hides its pages with `set_child_visible()` -- and a widget that
|
||||||
|
// is not on screen is not one to leave the spotlight standing on.
|
||||||
|
const bool target_present = target != nullptr && gtk_widget_get_mapped(target);
|
||||||
|
if (target_present && gtk_widget_compute_bounds(target, view.overlay, &bounds)) {
|
||||||
|
// The hole: the target, grown by three pixels so the outline sits *around* the widget rather than
|
||||||
|
// on it, and clamped to the overlay -- a target partly off screen is an ordinary case (a sidebar
|
||||||
|
// dragged wide), and a negative rectangle is not something a margin can express.
|
||||||
|
const int x0 = static_cast<int>(std::floor(bounds.origin.x)) - 3;
|
||||||
|
const int y0 = static_cast<int>(std::floor(bounds.origin.y)) - 3;
|
||||||
|
const int x1 = static_cast<int>(std::ceil(bounds.origin.x + bounds.size.width)) + 3;
|
||||||
|
const int y1 = static_cast<int>(std::ceil(bounds.origin.y + bounds.size.height)) + 3;
|
||||||
|
view.hole[0] = std::clamp(x0, 0, w);
|
||||||
|
view.hole[1] = std::clamp(y0, 0, h);
|
||||||
|
view.hole[2] = std::clamp(x1, 0, w) - view.hole[0];
|
||||||
|
view.hole[3] = std::clamp(y1, 0, h) - view.hole[1];
|
||||||
|
} else if (!target_present) {
|
||||||
|
// Nothing on screen to point at: the whole surface dims and the card takes a corner. A step whose
|
||||||
|
// target went away mid-tour is not a step to be skipped silently.
|
||||||
|
view.hole[0] = view.hole[1] = view.hole[2] = view.hole[3] = 0;
|
||||||
|
}
|
||||||
|
// ... and when the target *is* there but could not be measured, the previous hole stands: a frame
|
||||||
|
// can arrive between two layout passes, and emptying the hole there is the spotlight and the card
|
||||||
|
// jumping to the corner and back.
|
||||||
|
const int hx = view.hole[0];
|
||||||
|
const int hy = view.hole[1];
|
||||||
|
const int hw = view.hole[2];
|
||||||
|
const int hh = view.hole[3];
|
||||||
|
|
||||||
|
place(view.dim[0], 0, 0, w, hy); // above the hole
|
||||||
|
place(view.dim[1], 0, hy + hh, w, h - hy - hh); // below it
|
||||||
|
place(view.dim[2], 0, hy, hx, hh); // to its left
|
||||||
|
place(view.dim[3], hx + hw, hy, w - hx - hw, hh); // to its right
|
||||||
|
place(view.spot, hx, hy, hw, hh);
|
||||||
|
|
||||||
|
// The card, placed clear of the hole whenever the window has room for it. The strip order is the
|
||||||
|
// order a card is easiest to read in: under the thing it describes, then over it, then in
|
||||||
|
// whichever column is beside it -- which is, on this window, the side panel for the plot's step and
|
||||||
|
// the plot for every step in the side panel.
|
||||||
|
//
|
||||||
|
// The card's size is the *rectangle it was allocated*, read the same way the probe reads it and
|
||||||
|
// the help's figures are drawn from: `gtk_widget_compute_bounds()`. Not `gtk_widget_get_width()`,
|
||||||
|
// which is the widget's *content* size -- a border and a padding short of the rectangle on screen
|
||||||
|
// (measured here: 254 against the card's real 280), so a card placed from it overhangs its own size
|
||||||
|
// into the target; that call is read only to tell a frame that has been laid out from one that is
|
||||||
|
// too early. And, once a frame has run, not a measure either:
|
||||||
|
// * `gtk_widget_measure()` reports the size *including the widget's own margins* (a 280 px box
|
||||||
|
// with `margin-start` 453 measures 733), and ours are the card's position -- reading one back
|
||||||
|
// without taking them off is a feedback loop.
|
||||||
|
// * asking for a **height** with a width (`for_size`) on a widget whose margins are then left out
|
||||||
|
// of that width is a question GTK refuses: it reports `Trying to measure GtkBox ... for width
|
||||||
|
// of N, but it needs at least M` once per frame, because the for_size derived from the natural
|
||||||
|
// width minus the margins lands one margin short of the minimum the box answers with. The
|
||||||
|
// allocation the widget was actually given cannot be short of it.
|
||||||
|
// A measure survives only as the fallback for a frame with nothing allocated yet (the first frame
|
||||||
|
// the card is shown, before any layout pass), and that fallback is taken with **no for_size at
|
||||||
|
// all**: `for_size == -1` is the form GTK measures without the check that warns.
|
||||||
|
int cw = 0;
|
||||||
|
int ch = 0;
|
||||||
|
graphene_rect_t card_bounds = GRAPHENE_RECT_INIT(0, 0, 0, 0);
|
||||||
|
if (gtk_widget_is_visible(view.card) && gtk_widget_get_width(view.card) > 0 &&
|
||||||
|
gtk_widget_compute_bounds(view.card, view.overlay, &card_bounds)) {
|
||||||
|
cw = static_cast<int>(std::ceil(card_bounds.size.width));
|
||||||
|
ch = static_cast<int>(std::ceil(card_bounds.size.height));
|
||||||
|
}
|
||||||
|
if (cw <= 0 || ch <= 0) {
|
||||||
|
gtk_widget_measure(view.card, GTK_ORIENTATION_HORIZONTAL, -1, nullptr, &cw, nullptr, nullptr);
|
||||||
|
cw = std::max(kCardWidth, cw - gtk_widget_get_margin_start(view.card) -
|
||||||
|
gtk_widget_get_margin_end(view.card));
|
||||||
|
gtk_widget_measure(view.card, GTK_ORIENTATION_VERTICAL, -1, nullptr, &ch, nullptr, nullptr);
|
||||||
|
ch = std::max(0, ch - gtk_widget_get_margin_top(view.card) -
|
||||||
|
gtk_widget_get_margin_bottom(view.card));
|
||||||
|
}
|
||||||
|
int cx = 0, cy = 0;
|
||||||
|
if (h - (hy + hh) >= ch + 2 * kGap) {
|
||||||
|
cx = std::clamp(hx, kGap, std::max(kGap, w - cw - kGap));
|
||||||
|
cy = hy + hh + kGap;
|
||||||
|
} else if (hy >= ch + 2 * kGap) {
|
||||||
|
cx = std::clamp(hx, kGap, std::max(kGap, w - cw - kGap));
|
||||||
|
cy = hy - ch - kGap;
|
||||||
|
} else if (w - (hx + hw) >= cw + 2 * kGap) {
|
||||||
|
cx = hx + hw + kGap;
|
||||||
|
cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap));
|
||||||
|
} else if (hx >= cw + 2 * kGap) {
|
||||||
|
cx = hx - cw - kGap;
|
||||||
|
cy = std::clamp(hy, kGap, std::max(kGap, h - ch - kGap));
|
||||||
|
} else {
|
||||||
|
// No clear side at all -- the target is nearly the whole window, which is the plot's step in a
|
||||||
|
// window narrow enough for the split view to *overlay* the side panel rather than put it beside
|
||||||
|
// the plot. The card goes to the bottom-right corner, which is where the least of the target is,
|
||||||
|
// and the two readings the probe prints (`spot=`, `card=`) are what show how little choice there
|
||||||
|
// was rather than the card having been placed badly.
|
||||||
|
cx = std::max(kGap, w - cw - kGap);
|
||||||
|
cy = std::max(kGap, h - ch - kGap);
|
||||||
|
}
|
||||||
|
place_natural(view.card, cx, cy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the main window's tour, and its table of stops --------------------------
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// One stop of the *window's* tour. The target is measured, never named by a stored rectangle: the
|
||||||
|
/// point of re-measuring every frame is that nothing here knows where the widget is.
|
||||||
|
struct TourStep {
|
||||||
|
const char* id;
|
||||||
|
/// The sidebar page the target lives on, or nullptr when the stop is not on either page. The
|
||||||
|
/// trigger and the channel list are both on the Signals page, so a stop about either has to put
|
||||||
|
/// the sidebar there before it can spotlight one.
|
||||||
|
const char* page;
|
||||||
|
const char* title;
|
||||||
|
const char* body;
|
||||||
|
GtkWidget* App::* target;
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr TourStep kTourSteps[] = {
|
||||||
|
{"plot", nullptr, "The plot",
|
||||||
|
"Every signal you have switched on is drawn here, all rows sharing one time axis. The wheel "
|
||||||
|
"zooms, a drag pans, and a right-click on a row opens that plot's own menu.",
|
||||||
|
&App::area_overlay},
|
||||||
|
{"side", nullptr, "The side panel",
|
||||||
|
"The working surface beside the plot. Measure holds the cursors and the measurements; Signals "
|
||||||
|
"holds the trigger and the list of signals. The buttons in the bar above show and hide it.",
|
||||||
|
&App::side_box},
|
||||||
|
{"trigger", "signals", "The trigger",
|
||||||
|
"Describe an event -- a signal, a condition, a level and the window around it -- and arm it: "
|
||||||
|
"the view then freezes on the first event that matches, however rare it is.",
|
||||||
|
&App::trigger_body},
|
||||||
|
{"signals", "signals", "The signals",
|
||||||
|
"Every signal the source declares, each with the switch that acquires it. A signal has to be "
|
||||||
|
"switched on before it can be plotted, measured or searched.",
|
||||||
|
&App::signal_list},
|
||||||
|
{"status", nullptr, "The status line",
|
||||||
|
"The stream's own report, once a second: the display mode, the rate arriving, the window on "
|
||||||
|
"screen and the buffer holding it. The information button opens the counters behind it.",
|
||||||
|
&App::status_row},
|
||||||
|
};
|
||||||
|
constexpr int kTourStepCount = static_cast<int>(sizeof(kTourSteps) / sizeof(kTourSteps[0]));
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void build_tour(App& app) {
|
||||||
|
tour_build(app.tour, app.tour.overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
void start_tour(App& app) {
|
||||||
|
if (app.tour.overlay == nullptr || app.tour.card == nullptr) return;
|
||||||
|
// "The tour has run" is written when it *starts*: a user who skips it has still seen it, and the
|
||||||
|
// alternative -- marking it on the last step -- would offer the whole tour again to everyone who
|
||||||
|
// decided in the first second that they did not want it.
|
||||||
|
if (!app.settings.tour_seen) {
|
||||||
|
app.settings.tour_seen = true;
|
||||||
|
(void)save_settings(app.settings);
|
||||||
|
}
|
||||||
|
// The sidebar's own stack, so a stop can name the page it stands on. Set here and not in
|
||||||
|
// `build_tour()`: the overlay is inserted above the window's content *before* `App::side_stack` is
|
||||||
|
// assigned, so the member does not exist yet at build time -- and the walk cannot start before the
|
||||||
|
// window is up anyway.
|
||||||
|
app.tour.page_stack = app.side_stack;
|
||||||
|
// The whole table, in walk order: `tour_begin()` drops the stops whose surface is not on screen.
|
||||||
|
std::vector<TourStop> stops;
|
||||||
|
stops.reserve(kTourStepCount);
|
||||||
|
for (int i = 0; i < kTourStepCount; ++i) {
|
||||||
|
GtkWidget* target = app.*(kTourSteps[i].target);
|
||||||
|
stops.push_back({target, kTourSteps[i].page, kTourSteps[i].title, kTourSteps[i].body});
|
||||||
|
}
|
||||||
|
tour_begin(app.tour, std::move(stops), nullptr, nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
void tour_step(App& app, int delta) { tour_advance(app.tour, delta); }
|
||||||
|
|
||||||
|
void tour_dismiss(App& app) { tour_end(app.tour); }
|
||||||
|
|
||||||
|
bool tour_active(const App& app) { return app.tour.running; }
|
||||||
|
|
||||||
|
int tour_step_count(const App& app) { return static_cast<int>(app.tour.plan.size()); }
|
||||||
|
|
||||||
|
int tour_step_index(const App& app) { return app.tour.index; }
|
||||||
|
|
||||||
|
void update_tour(App& app) { tour_update(app.tour); }
|
||||||
|
|
||||||
|
GtkWidget* tour_target(App& app) { return tour_target(app.tour); }
|
||||||
|
|
||||||
|
gboolean tour_autostart(gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
if (app->window == nullptr || app->tour.overlay == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
app->tour_auto_started = true;
|
||||||
|
start_tour(*app);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
gboolean tour_start_requested(gpointer user) {
|
||||||
|
App* app = static_cast<App*>(user);
|
||||||
|
if (app->window == nullptr || app->tour.overlay == nullptr) return G_SOURCE_REMOVE;
|
||||||
|
// Not `tour_auto_started`: the chooser's entry is the user asking for the tour before the window
|
||||||
|
// existed, and the reading has to say which of the two started it.
|
||||||
|
app->tour_requested = true;
|
||||||
|
start_tour(*app);
|
||||||
|
return G_SOURCE_REMOVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the dump's readings ----------------------------------------------------
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
/// The surfaces the help's annotated figures label. Their geometry is what a figure's crop box is
|
||||||
|
/// drawn from, and it has to be *measured* -- at 1280x800 the panels are not where a plausible guess
|
||||||
|
/// puts them, and a box drawn from a guess is a box over the wrong thing.
|
||||||
|
struct FigureSurface {
|
||||||
|
const char* name;
|
||||||
|
GtkWidget* App::* widget;
|
||||||
|
};
|
||||||
|
|
||||||
|
constexpr FigureSurface kFigureSurfaces[] = {
|
||||||
|
{"trigger_head", &App::trigger_head},
|
||||||
|
{"trigger_summary", &App::trigger_summary},
|
||||||
|
{"trigger_panel", &App::trigger_panel},
|
||||||
|
{"signal_list", &App::signal_list},
|
||||||
|
{"side_switcher", &App::side_switcher},
|
||||||
|
{"side_stack", &App::side_stack},
|
||||||
|
{"window_button", &App::window_button},
|
||||||
|
{"status_label", &App::status_label},
|
||||||
|
{"header_bar", &App::toolbar},
|
||||||
|
{"plot", &App::area_overlay},
|
||||||
|
};
|
||||||
|
constexpr int kFigureSurfaceCount =
|
||||||
|
static_cast<int>(sizeof(kFigureSurfaces) / sizeof(kFigureSurfaces[0]));
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void capture_help_state(App& app) {
|
||||||
|
std::string ids;
|
||||||
|
const int hints = context_help_count(app, &ids);
|
||||||
|
// The icon is read back through the theme for the reason the pane [edit] buttons are: a name
|
||||||
|
// Adwaita does not have is a *blank* control that works and reads as nothing at all. The size is
|
||||||
|
// the reading a "much tinier" affordance is checked against, and it is measured the way every
|
||||||
|
// other rectangle here is (`gtk_widget_compute_bounds()`), on the tick while the widget is alive.
|
||||||
|
GtkIconTheme* theme = gtk_icon_theme_get_for_display(gdk_display_get_default());
|
||||||
|
const bool icon_ok = theme != nullptr && gtk_icon_theme_has_icon(theme, "dialog-information-symbolic");
|
||||||
|
int hint_w = 0;
|
||||||
|
int hint_h = 0;
|
||||||
|
context_help_size(app, &hint_w, &hint_h);
|
||||||
|
char layer[640];
|
||||||
|
std::snprintf(layer, sizeof(layer),
|
||||||
|
"contextual=%s hints=%d ids={%s} icon=dialog-information-symbolic(%s) hint=%dx%d; "
|
||||||
|
"tour=%s steps=%d step=%d auto=%d requested=%d",
|
||||||
|
app.settings.contextual_help ? "on" : "off", hints, ids.empty() ? "" : ids.c_str(),
|
||||||
|
icon_ok ? "ok" : "MISSING", hint_w, hint_h,
|
||||||
|
app.tour.running ? "running" : "idle", tour_step_count(app), tour_step_index(app),
|
||||||
|
app.tour_auto_started ? 1 : 0, app.tour_requested ? 1 : 0);
|
||||||
|
app.dbg_context_help = layer;
|
||||||
|
app.dbg_tour_steps = tour_step_count(app);
|
||||||
|
app.dbg_tour_step = tour_step_index(app);
|
||||||
|
app.dbg_tour_visible = app.tour.running ? 1 : 0;
|
||||||
|
|
||||||
|
std::string figures;
|
||||||
|
for (int i = 0; i < kFigureSurfaceCount; ++i) {
|
||||||
|
const FigureSurface& surface = kFigureSurfaces[i];
|
||||||
|
GtkWidget* widget = app.*(surface.widget);
|
||||||
|
graphene_rect_t r = GRAPHENE_RECT_INIT(0.0f, 0.0f, 0.0f, 0.0f);
|
||||||
|
int x = -1, y = -1, width = 0, height = 0;
|
||||||
|
// `is_visible()`, not `get_visible()`: a widget under a hidden parent -- the trigger summary
|
||||||
|
// while the pane is in configuration mode, the Measure page while the Signals tab is showing --
|
||||||
|
// keeps its own flag true and reads 0x0, which a crop box would take for a real rectangle.
|
||||||
|
if (widget != nullptr && gtk_widget_is_visible(widget) &&
|
||||||
|
gtk_widget_compute_bounds(widget, app.window, &r)) {
|
||||||
|
x = static_cast<int>(std::lround(r.origin.x));
|
||||||
|
y = static_cast<int>(std::lround(r.origin.y));
|
||||||
|
width = static_cast<int>(std::lround(r.size.width));
|
||||||
|
height = static_cast<int>(std::lround(r.size.height));
|
||||||
|
}
|
||||||
|
if (!figures.empty()) figures.push_back(' ');
|
||||||
|
figures += std::string(surface.name) + "=" + std::to_string(x) + "," + std::to_string(y) + "," +
|
||||||
|
std::to_string(width) + "," + std::to_string(height);
|
||||||
|
}
|
||||||
|
app.dbg_help_figures = figures;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace ui_impl
|
||||||
|
} // namespace udpscope
|
||||||
+316
-20
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
#include "ui_app.hpp"
|
#include "ui_app.hpp"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
#include <gtk/gtk.h>
|
#include <gtk/gtk.h>
|
||||||
|
|
||||||
#include <string>
|
#include <string>
|
||||||
@@ -12,6 +14,31 @@
|
|||||||
namespace udpscope {
|
namespace udpscope {
|
||||||
namespace ui_impl {
|
namespace ui_impl {
|
||||||
|
|
||||||
|
/// Both arm buttons from the one state, so the panel's and the compact line's cannot disagree about what
|
||||||
|
/// the engine is doing. Three properties, three sources, and they are deliberately not the same source:
|
||||||
|
/// the *label* is the producer's atomic (no lock), the *sensitivity* and the compact button's
|
||||||
|
/// *visibility* are the toolbar toggle's state -- the toggle is what owns `enabled`, and reading it
|
||||||
|
/// instead of the model keeps this callable from a signal handler that does not hold, and must not take,
|
||||||
|
/// the model lock. Visibility is `enabled && locked`: with trigger mode off the line's own text says
|
||||||
|
/// "stream (trigger off)" and there is no engine to arm, and with the pane open the body's button is the
|
||||||
|
/// one on screen -- never both at once.
|
||||||
|
void sync_arm_buttons(App& app) {
|
||||||
|
const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed);
|
||||||
|
const bool enabled =
|
||||||
|
app.trigger_toggle != nullptr &&
|
||||||
|
gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle)) != FALSE;
|
||||||
|
const char* label = armed ? "Disarm" : "Arm";
|
||||||
|
if (app.arm_button != nullptr) {
|
||||||
|
gtk_button_set_label(GTK_BUTTON(app.arm_button), label);
|
||||||
|
gtk_widget_set_sensitive(app.arm_button, enabled);
|
||||||
|
}
|
||||||
|
if (app.trigger_summary_arm != nullptr) {
|
||||||
|
gtk_button_set_label(GTK_BUTTON(app.trigger_summary_arm), label);
|
||||||
|
gtk_widget_set_sensitive(app.trigger_summary_arm, enabled);
|
||||||
|
gtk_widget_set_visible(app.trigger_summary_arm, enabled && app.trigger_locked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void on_arm_clicked(GtkButton* button, gpointer user) {
|
void on_arm_clicked(GtkButton* button, gpointer user) {
|
||||||
App* app = static_cast<App*>(user);
|
App* app = static_cast<App*>(user);
|
||||||
(void)button;
|
(void)button;
|
||||||
@@ -35,11 +62,212 @@ void on_arm_clicked(GtkButton* button, gpointer user) {
|
|||||||
// is switched on with it -- otherwise the trigger could never see a sample.
|
// is switched on with it -- otherwise the trigger could never see a sample.
|
||||||
// Outside the lock: set_signal_enabled() takes it.
|
// Outside the lock: set_signal_enabled() takes it.
|
||||||
if (armed && signal >= 0) app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
|
if (armed && signal >= 0) app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
|
||||||
gtk_button_set_label(GTK_BUTTON(app->arm_button), armed ? "Disarm" : "Arm");
|
sync_arm_buttons(*app);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists
|
/// The trigger type dropdown's order, as a table rather than a cast: the dropdown lists
|
||||||
/// only the conditions that are implemented, so its index is not `TriggerType`'s value.
|
/// only the conditions that are implemented, so its index is not `TriggerType`'s value.
|
||||||
|
/// The trigger's four enumerations as words, and back. These are the panel's dropdown labels *and*
|
||||||
|
/// what the session file stores: an index would tie the file to a declaration order, which is the
|
||||||
|
/// lesson `math_op_at()` already carries, and two vocabularies (one for the UI, one for the file)
|
||||||
|
/// drift apart the moment one of them gains a member.
|
||||||
|
const char* trigger_type_text(TriggerType t) {
|
||||||
|
switch (t) {
|
||||||
|
case TriggerType::Edge: return "edge";
|
||||||
|
case TriggerType::PulseWidth: return "pulse width";
|
||||||
|
case TriggerType::Window: return "window";
|
||||||
|
case TriggerType::Dropout: return "dropout";
|
||||||
|
}
|
||||||
|
return "edge";
|
||||||
|
}
|
||||||
|
TriggerType trigger_type_from_text(const std::string& s) {
|
||||||
|
if (s == "pulse width") return TriggerType::PulseWidth;
|
||||||
|
if (s == "window") return TriggerType::Window;
|
||||||
|
if (s == "dropout") return TriggerType::Dropout;
|
||||||
|
return TriggerType::Edge;
|
||||||
|
}
|
||||||
|
const char* trigger_edge_text(TriggerEdge e) {
|
||||||
|
switch (e) {
|
||||||
|
case TriggerEdge::Rising: return "rising";
|
||||||
|
case TriggerEdge::Falling: return "falling";
|
||||||
|
case TriggerEdge::Both: return "both";
|
||||||
|
}
|
||||||
|
return "rising";
|
||||||
|
}
|
||||||
|
TriggerEdge trigger_edge_from_text(const std::string& s) {
|
||||||
|
if (s == "falling") return TriggerEdge::Falling;
|
||||||
|
if (s == "both") return TriggerEdge::Both;
|
||||||
|
return TriggerEdge::Rising;
|
||||||
|
}
|
||||||
|
const char* width_cmp_text(WidthCmp c) {
|
||||||
|
switch (c) {
|
||||||
|
case WidthCmp::Less: return "<";
|
||||||
|
case WidthCmp::Greater: return ">";
|
||||||
|
case WidthCmp::Within: return "within";
|
||||||
|
case WidthCmp::Outside: return "outside";
|
||||||
|
}
|
||||||
|
return ">";
|
||||||
|
}
|
||||||
|
WidthCmp width_cmp_from_text(const std::string& s) {
|
||||||
|
if (s == "<") return WidthCmp::Less;
|
||||||
|
if (s == "within") return WidthCmp::Within;
|
||||||
|
if (s == "outside") return WidthCmp::Outside;
|
||||||
|
return WidthCmp::Greater;
|
||||||
|
}
|
||||||
|
const char* trigger_mode_text(TriggerMode m) {
|
||||||
|
return m == TriggerMode::Single ? "single" : "normal";
|
||||||
|
}
|
||||||
|
TriggerMode trigger_mode_from_text(const std::string& s) {
|
||||||
|
return s == "single" ? TriggerMode::Single : TriggerMode::Normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dropdown's row for a piece of text, or 0. Asked of the widget's own model, so the mapping from
|
||||||
|
/// a stored word to a selection is the list the user sees and not a second table to keep in step.
|
||||||
|
static guint dropdown_index_of(GtkWidget* drop, const char* text) {
|
||||||
|
GListModel* model = drop != nullptr ? gtk_drop_down_get_model(GTK_DROP_DOWN(drop)) : nullptr;
|
||||||
|
const guint n = model != nullptr ? g_list_model_get_n_items(model) : 0;
|
||||||
|
for (guint i = 0; i < n; ++i) {
|
||||||
|
GtkStringObject* item = GTK_STRING_OBJECT(g_list_model_get_item(model, i));
|
||||||
|
const bool hit = item != nullptr && std::strcmp(gtk_string_object_get_string(item), text) == 0;
|
||||||
|
if (item != nullptr) g_object_unref(item);
|
||||||
|
if (hit) return i;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The panel's controls from the model: what a session load has just put in the model, so the pane
|
||||||
|
/// shows the trigger it is about to run. Guarded, because a control's own handler publishes what it
|
||||||
|
/// holds and this is the other direction.
|
||||||
|
void write_trigger_widgets(App& app) {
|
||||||
|
if (app.trigger_level_spin == nullptr) return;
|
||||||
|
TriggerConfig tc;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||||
|
tc = app.model->trigger();
|
||||||
|
}
|
||||||
|
const bool was = app.updating_panels;
|
||||||
|
app.updating_panels = true;
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level_spin), tc.condition.level);
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_pre_spin), tc.pretrigger_pct);
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_holdoff_spin), tc.holdoff_s);
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_width_spin), tc.condition.width_s);
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_width2_spin), tc.condition.width2_s);
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_level2_spin), tc.condition.level2);
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_timeout_spin), tc.condition.timeout_s);
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_band_spin), tc.condition.noise_band);
|
||||||
|
gtk_spin_button_set_value(GTK_SPIN_BUTTON(app.trigger_hf_spin), tc.condition.hf_fc);
|
||||||
|
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_type_drop),
|
||||||
|
dropdown_index_of(app.trigger_type_drop,
|
||||||
|
trigger_type_text(tc.condition.type)));
|
||||||
|
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_edge_drop),
|
||||||
|
dropdown_index_of(app.trigger_edge_drop,
|
||||||
|
trigger_edge_text(tc.condition.edge)));
|
||||||
|
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_cmp_drop),
|
||||||
|
dropdown_index_of(app.trigger_cmp_drop,
|
||||||
|
width_cmp_text(tc.condition.cmp)));
|
||||||
|
gtk_drop_down_set_selected(GTK_DROP_DOWN(app.trigger_mode_drop),
|
||||||
|
dropdown_index_of(app.trigger_mode_drop,
|
||||||
|
trigger_mode_text(tc.mode)));
|
||||||
|
app.updating_panels = was;
|
||||||
|
sync_trigger_rows(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The compact line: what the pane is set to, in the words the panel's own controls use. Read from the
|
||||||
|
/// model rather than from the widgets, because the widgets are not on screen in this mode and are not
|
||||||
|
/// rebuilt when a session loads.
|
||||||
|
void sync_trigger_summary(App& app) {
|
||||||
|
if (app.trigger_summary == nullptr) return;
|
||||||
|
int row = 0;
|
||||||
|
std::string reading; // the same content as one line, for the dump
|
||||||
|
const auto put = [&](const char* key, const std::string& value) {
|
||||||
|
if (row >= App::kTriggerSummaryRows) return;
|
||||||
|
gtk_label_set_text(GTK_LABEL(app.trigger_summary_keys[row]), key);
|
||||||
|
gtk_label_set_text(GTK_LABEL(app.trigger_summary_values[row]), value.c_str());
|
||||||
|
gtk_widget_set_visible(app.trigger_summary_keys[row], TRUE);
|
||||||
|
gtk_widget_set_visible(app.trigger_summary_values[row], TRUE);
|
||||||
|
reading += std::string(reading.empty() ? "" : " | ") + key + ": " + value;
|
||||||
|
++row;
|
||||||
|
};
|
||||||
|
const auto ms = [](double seconds) {
|
||||||
|
char b[64];
|
||||||
|
std::snprintf(b, sizeof(b), "%.6g ms", seconds * 1000.0);
|
||||||
|
return std::string(b);
|
||||||
|
};
|
||||||
|
const auto number = [](double v) {
|
||||||
|
char b[64];
|
||||||
|
std::snprintf(b, sizeof(b), "%.6g", v);
|
||||||
|
return std::string(b);
|
||||||
|
};
|
||||||
|
TriggerConfig tc;
|
||||||
|
std::string channel;
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(app.model->mutex());
|
||||||
|
tc = app.model->trigger();
|
||||||
|
const std::vector<ChannelInfo>& channels = app.model->channels();
|
||||||
|
if (tc.channel >= 0 && tc.channel < static_cast<int>(channels.size())) {
|
||||||
|
channel = channels[static_cast<std::size_t>(tc.channel)].name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// With trigger mode off the scope is streaming, and that is the whole of what this pane has to say:
|
||||||
|
// the four lines below describe a trigger that is not running.
|
||||||
|
if (!tc.enabled) {
|
||||||
|
put("mode", "stream (trigger off)");
|
||||||
|
for (int i = row; i < App::kTriggerSummaryRows; ++i) {
|
||||||
|
gtk_widget_set_visible(app.trigger_summary_keys[i], FALSE);
|
||||||
|
gtk_widget_set_visible(app.trigger_summary_values[i], FALSE);
|
||||||
|
}
|
||||||
|
app.dbg_trigger_summary = reading;
|
||||||
|
gtk_widget_set_visible(app.trigger_summary, app.trigger_locked);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
put("signal", channel.empty() ? std::string("none acquired") : channel);
|
||||||
|
// The condition: the shape, and whatever qualifies it. A pulse width carries its comparison and its
|
||||||
|
// width, which is what the shape *is*; a window carries its two levels on the line below.
|
||||||
|
std::string condition = trigger_type_text(tc.condition.type);
|
||||||
|
switch (tc.condition.type) {
|
||||||
|
case TriggerType::Edge:
|
||||||
|
case TriggerType::Window:
|
||||||
|
condition += std::string(", ") + trigger_edge_text(tc.condition.edge);
|
||||||
|
break;
|
||||||
|
case TriggerType::PulseWidth:
|
||||||
|
condition += std::string(", ") + width_cmp_text(tc.condition.cmp) + " " + ms(tc.condition.width_s);
|
||||||
|
if (tc.condition.cmp == WidthCmp::Within || tc.condition.cmp == WidthCmp::Outside) {
|
||||||
|
condition += " .. " + ms(tc.condition.width2_s);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case TriggerType::Dropout:
|
||||||
|
condition += std::string(", ") + ms(tc.condition.timeout_s);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
put("condition", condition);
|
||||||
|
switch (tc.condition.type) {
|
||||||
|
case TriggerType::Window:
|
||||||
|
put("level", number(tc.condition.level) + " .. " + number(tc.condition.level2));
|
||||||
|
break;
|
||||||
|
case TriggerType::Dropout:
|
||||||
|
put("timeout", ms(tc.condition.timeout_s));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
put("level", number(tc.condition.level));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
put("mode", trigger_mode_text(tc.mode));
|
||||||
|
// Everything else only when it is not at its default: a line that says "band 0" is a line that says
|
||||||
|
// nothing, and the four lines above are the ones a user reads.
|
||||||
|
if (tc.condition.noise_band > 0.0 && tc.condition.type != TriggerType::Dropout) {
|
||||||
|
put("noise band", number(tc.condition.noise_band));
|
||||||
|
}
|
||||||
|
if (tc.condition.hf_fc > 0.0) put("HF reject", number(tc.condition.hf_fc) + " Hz");
|
||||||
|
if (std::fabs(tc.pretrigger_pct - 50.0) > 1.0e-9) put("pre-trigger", number(tc.pretrigger_pct) + " %");
|
||||||
|
if (std::fabs(tc.holdoff_s - 0.010) > 1.0e-12) put("hold-off", ms(tc.holdoff_s));
|
||||||
|
for (int i = row; i < App::kTriggerSummaryRows; ++i) {
|
||||||
|
gtk_widget_set_visible(app.trigger_summary_keys[i], FALSE);
|
||||||
|
gtk_widget_set_visible(app.trigger_summary_values[i], FALSE);
|
||||||
|
}
|
||||||
|
app.dbg_trigger_summary = reading;
|
||||||
|
gtk_widget_set_visible(app.trigger_summary, app.trigger_locked);
|
||||||
|
}
|
||||||
|
|
||||||
TriggerType trigger_type_at(guint i) {
|
TriggerType trigger_type_at(guint i) {
|
||||||
switch (i) {
|
switch (i) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -195,28 +423,29 @@ void on_trigger_toggled(GtkToggleButton* button, gpointer user) {
|
|||||||
if (on && signal >= 0) {
|
if (on && signal >= 0) {
|
||||||
app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
|
app->model->set_signal_enabled(static_cast<std::size_t>(signal), true);
|
||||||
}
|
}
|
||||||
// Disabled, not hidden: a panel you can still see is a panel you can read, and the settings you
|
// The panel's body stays *live* whether or not the trigger is running: a trigger is configured
|
||||||
// are about to switch back on must not vanish while you look for them. The *body* is what goes
|
// before it is switched on, and switching it off to edit it -- or to check what it is set to --
|
||||||
// insensitive and not the panel, because the panel's heading row carries the switch that turns
|
// is the ordinary way to work. (It used to go insensitive with the mode, which left the settings
|
||||||
// this back on -- and a control inside an insensitive parent cannot be clicked.
|
// unreachable exactly when a user wanted to change them.) What that costs is the reading: the
|
||||||
gtk_widget_set_sensitive(app->trigger_body, on);
|
// `trigger panel sensitive` line is now a regression guard, and a `0` there means the
|
||||||
|
// configuration has been locked out again.
|
||||||
// The event-history button is part of trigger mode and the pane it opens is *on demand*; leaving
|
// The event-history button is part of trigger mode and the pane it opens is *on demand*; leaving
|
||||||
// trigger mode takes the button away and collapses the pane, so going back to streaming is not a
|
// trigger mode takes the button away and collapses the pane, so going back to streaming is not a
|
||||||
// column of stale events beside the plot. Recording is the other half of that answer, so this is
|
// column of stale events beside the plot. Recording is the other half of that answer, so this is
|
||||||
// `sync_events_availability()`'s call and not the trigger's alone.
|
// `sync_events_availability()`'s call and not the trigger's alone.
|
||||||
sync_events_availability(*app);
|
sync_events_availability(*app);
|
||||||
gtk_button_set_label(GTK_BUTTON(app->arm_button), on ? "Disarm" : "Arm");
|
// Arming is a *runtime* control: with trigger mode off there is no engine to arm, and the arm buttons
|
||||||
// The pane's own switch shows the same state, whichever of the two was clicked.
|
// are the one part of this pane that is not a setting. (The body itself stays live -- see above.) Both
|
||||||
|
// of them are stated from this switch's new state in one place, so they cannot disagree with each other.
|
||||||
|
sync_arm_buttons(*app);
|
||||||
|
// The pane's own switch shows the same state, whichever of the two was clicked, and the compact line
|
||||||
|
// changes shape with it: with trigger mode off it says "stream" and nothing else.
|
||||||
sync_panel_switches(*app);
|
sync_panel_switches(*app);
|
||||||
|
sync_trigger_summary(*app);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The way out of trigger mode, from inside the panel that froze the view: it is
|
/// The way out of trigger mode, from inside the panel that froze the view: it is
|
||||||
/// the toolbar's trigger switch, so there is exactly one piece of state.
|
/// the toolbar's trigger switch, so there is exactly one piece of state.
|
||||||
void on_go_live(GtkButton*, gpointer user) {
|
|
||||||
App* app = static_cast<App*>(user);
|
|
||||||
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(app->trigger_toggle), FALSE);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The row the `.ui` declares for a control, with the control appended to it.
|
/// The row the `.ui` declares for a control, with the control appended to it.
|
||||||
///
|
///
|
||||||
/// The label is the file's, because it is text a translator owns; the control is code's, because a
|
/// The label is the file's, because it is text a translator owns; the control is code's, because a
|
||||||
@@ -248,17 +477,24 @@ GtkWidget* build_trigger_panel(App& app) {
|
|||||||
G_CALLBACK(on_trigger_signal_changed), &app);
|
G_CALLBACK(on_trigger_signal_changed), &app);
|
||||||
row_for(ui, "signal_row", app.trigger_signal_drop);
|
row_for(ui, "signal_row", app.trigger_signal_drop);
|
||||||
|
|
||||||
const char* types[] = {"edge", "pulse width", "window", "dropout", nullptr};
|
const char* types[] = {trigger_type_text(TriggerType::Edge),
|
||||||
|
trigger_type_text(TriggerType::PulseWidth),
|
||||||
|
trigger_type_text(TriggerType::Window),
|
||||||
|
trigger_type_text(TriggerType::Dropout), nullptr};
|
||||||
app.trigger_type_drop = gtk_drop_down_new_from_strings(types);
|
app.trigger_type_drop = gtk_drop_down_new_from_strings(types);
|
||||||
gtk_widget_set_tooltip_text(app.trigger_type_drop,
|
gtk_widget_set_tooltip_text(app.trigger_type_drop,
|
||||||
"what counts as an event; the fields below change to suit it");
|
"what counts as an event; the fields below change to suit it");
|
||||||
row_for(ui, "condition_row", app.trigger_type_drop);
|
row_for(ui, "condition_row", app.trigger_type_drop);
|
||||||
|
|
||||||
const char* edges[] = {"rising", "falling", "both", nullptr};
|
const char* edges[] = {trigger_edge_text(TriggerEdge::Rising),
|
||||||
|
trigger_edge_text(TriggerEdge::Falling),
|
||||||
|
trigger_edge_text(TriggerEdge::Both), nullptr};
|
||||||
app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges);
|
app.trigger_edge_drop = gtk_drop_down_new_from_strings(edges);
|
||||||
row_for(ui, "edge_row", app.trigger_edge_drop);
|
row_for(ui, "edge_row", app.trigger_edge_drop);
|
||||||
|
|
||||||
const char* cmps[] = {"<", ">", "within", "outside", nullptr};
|
const char* cmps[] = {width_cmp_text(WidthCmp::Less), width_cmp_text(WidthCmp::Greater),
|
||||||
|
width_cmp_text(WidthCmp::Within), width_cmp_text(WidthCmp::Outside),
|
||||||
|
nullptr};
|
||||||
app.trigger_cmp_drop = gtk_drop_down_new_from_strings(cmps);
|
app.trigger_cmp_drop = gtk_drop_down_new_from_strings(cmps);
|
||||||
gtk_widget_set_tooltip_text(app.trigger_cmp_drop,
|
gtk_widget_set_tooltip_text(app.trigger_cmp_drop,
|
||||||
"how the measured pulse width is compared: '<' is the glitch "
|
"how the measured pulse width is compared: '<' is the glitch "
|
||||||
@@ -294,7 +530,10 @@ GtkWidget* build_trigger_panel(App& app) {
|
|||||||
row_for(ui, "level_row", app.trigger_level_spin);
|
row_for(ui, "level_row", app.trigger_level_spin);
|
||||||
|
|
||||||
app.trigger_pre_spin = spin(0.0, 99.0, 1.0, 0, 50.0);
|
app.trigger_pre_spin = spin(0.0, 99.0, 1.0, 0, 50.0);
|
||||||
row_for(ui, "pre_row", app.trigger_pre_spin);
|
// The row is kept, not just filled: the contextual help's "trigger_advanced" hint joins it, beside
|
||||||
|
// the pre-trigger share -- the panel's finer settings, which is what the ask calls the trigger's
|
||||||
|
// advanced ones.
|
||||||
|
app.trigger_pre_row = row_for(ui, "pre_row", app.trigger_pre_spin);
|
||||||
|
|
||||||
app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4,
|
app.trigger_holdoff_spin = spin(0.0, 3600.0, 0.001, 4,
|
||||||
app.model->config().trigger_holdoff_s);
|
app.model->config().trigger_holdoff_s);
|
||||||
@@ -307,7 +546,8 @@ GtkWidget* build_trigger_panel(App& app) {
|
|||||||
app.trigger_hf_spin = spin(0.0, 1.0e7, 1.0, 3, 0.0);
|
app.trigger_hf_spin = spin(0.0, 1.0e7, 1.0, 3, 0.0);
|
||||||
row_for(ui, "hf_row", app.trigger_hf_spin);
|
row_for(ui, "hf_row", app.trigger_hf_spin);
|
||||||
|
|
||||||
const char* modes[] = {"normal", "single", nullptr};
|
const char* modes[] = {trigger_mode_text(TriggerMode::Normal),
|
||||||
|
trigger_mode_text(TriggerMode::Single), nullptr};
|
||||||
app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes);
|
app.trigger_mode_drop = gtk_drop_down_new_from_strings(modes);
|
||||||
row_for(ui, "mode_row", app.trigger_mode_drop);
|
row_for(ui, "mode_row", app.trigger_mode_drop);
|
||||||
|
|
||||||
@@ -327,14 +567,15 @@ GtkWidget* build_trigger_panel(App& app) {
|
|||||||
gtk_widget_set_halign(app.events_record_switch, GTK_ALIGN_END);
|
gtk_widget_set_halign(app.events_record_switch, GTK_ALIGN_END);
|
||||||
gtk_widget_set_valign(app.events_record_switch, GTK_ALIGN_CENTER);
|
gtk_widget_set_valign(app.events_record_switch, GTK_ALIGN_CENTER);
|
||||||
gtk_box_append(GTK_BOX(record_row), app.events_record_switch);
|
gtk_box_append(GTK_BOX(record_row), app.events_record_switch);
|
||||||
|
// Where the contextual help's "events_record" hint joins -- beside the switch, never over it
|
||||||
|
// (see `ui_context_help.cpp`). Kept on the App because the layer's table names a field.
|
||||||
|
app.trigger_record_row = record_row;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.arm_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_arm"));
|
app.arm_button = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_arm"));
|
||||||
if (app.arm_button != nullptr) {
|
if (app.arm_button != nullptr) {
|
||||||
g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app);
|
g_signal_connect(app.arm_button, "clicked", G_CALLBACK(on_arm_clicked), &app);
|
||||||
}
|
}
|
||||||
GtkWidget* live = GTK_WIDGET(gtk_builder_get_object(ui, "trigger_live"));
|
|
||||||
if (live != nullptr) g_signal_connect(live, "clicked", G_CALLBACK(on_go_live), &app);
|
|
||||||
|
|
||||||
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin,
|
for (GtkWidget* w : {app.trigger_level_spin, app.trigger_pre_spin, app.trigger_holdoff_spin,
|
||||||
app.trigger_width_spin, app.trigger_width2_spin,
|
app.trigger_width_spin, app.trigger_width2_spin,
|
||||||
@@ -358,6 +599,11 @@ GtkWidget* build_trigger_panel(App& app) {
|
|||||||
app.search_pos_label = GTK_WIDGET(gtk_builder_get_object(ui, "search_pos"));
|
app.search_pos_label = GTK_WIDGET(gtk_builder_get_object(ui, "search_pos"));
|
||||||
app.search_next_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_next"));
|
app.search_next_button = GTK_WIDGET(gtk_builder_get_object(ui, "search_next"));
|
||||||
app.search_marks_label = GTK_WIDGET(gtk_builder_get_object(ui, "search_marks"));
|
app.search_marks_label = GTK_WIDGET(gtk_builder_get_object(ui, "search_marks"));
|
||||||
|
app.search_exp = GTK_WIDGET(gtk_builder_get_object(ui, "search_exp"));
|
||||||
|
// The expander's header box, where the contextual help's "trigger_search" hint joins as its last
|
||||||
|
// child (see `ui_context_help.cpp`): a hint on the block's own child would leave it unreachable
|
||||||
|
// while the block is collapsed.
|
||||||
|
app.trigger_search = GTK_WIDGET(gtk_builder_get_object(ui, "search_head"));
|
||||||
if (app.search_run_button != nullptr) {
|
if (app.search_run_button != nullptr) {
|
||||||
g_signal_connect(app.search_run_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
g_signal_connect(app.search_run_button, "clicked", G_CALLBACK(+[](GtkButton*, gpointer user) {
|
||||||
App* a = static_cast<App*>(user);
|
App* a = static_cast<App*>(user);
|
||||||
@@ -436,10 +682,38 @@ GtkWidget* build_trigger_panel(App& app) {
|
|||||||
gtk_widget_set_margin_top(head, 8);
|
gtk_widget_set_margin_top(head, 8);
|
||||||
gtk_widget_set_margin_start(head, 8);
|
gtk_widget_set_margin_start(head, 8);
|
||||||
gtk_widget_set_margin_end(head, 8);
|
gtk_widget_set_margin_end(head, 8);
|
||||||
|
// The row the contextual help's "?" for this pane joins (see `ui_context_help.cpp`): a sibling of
|
||||||
|
// the title and the controls, so a help button can never cover one of them. It is also one of the
|
||||||
|
// surfaces the help's annotated figures label, which is why the pointer outlives this function.
|
||||||
|
app.trigger_head = head;
|
||||||
GtkWidget* title = gtk_label_new("Trigger");
|
GtkWidget* title = gtk_label_new("Trigger");
|
||||||
gtk_widget_set_halign(title, GTK_ALIGN_START);
|
gtk_widget_set_halign(title, GTK_ALIGN_START);
|
||||||
gtk_widget_set_hexpand(title, TRUE);
|
gtk_widget_set_hexpand(title, TRUE);
|
||||||
gtk_box_append(GTK_BOX(head), title);
|
gtk_box_append(GTK_BOX(head), title);
|
||||||
|
// What the pane is set to, for the mode where its settings are not on screen.
|
||||||
|
app.trigger_summary = gtk_label_new("");
|
||||||
|
gtk_widget_add_css_class(app.trigger_summary, "dim-label");
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(app.trigger_summary), PANGO_ELLIPSIZE_END);
|
||||||
|
gtk_widget_set_halign(app.trigger_summary, GTK_ALIGN_START);
|
||||||
|
gtk_widget_set_hexpand(app.trigger_summary, TRUE);
|
||||||
|
gtk_widget_set_visible(app.trigger_summary, FALSE);
|
||||||
|
gtk_box_append(GTK_BOX(head), app.trigger_summary);
|
||||||
|
app.trigger_edit = gtk_button_new_from_icon_name("document-edit-symbolic");
|
||||||
|
gtk_widget_add_css_class(app.trigger_edit, "flat");
|
||||||
|
gtk_widget_set_valign(app.trigger_edit, GTK_ALIGN_CENTER);
|
||||||
|
gtk_widget_set_tooltip_text(app.trigger_edit, "configure the trigger");
|
||||||
|
g_signal_connect(app.trigger_edit, "clicked", G_CALLBACK(on_pane_edit_clicked), &app);
|
||||||
|
gtk_box_append(GTK_BOX(head), app.trigger_edit);
|
||||||
|
// The compact line's own arm control, built here beside the [edit] and the switch for the same reason
|
||||||
|
// the switch is: this row stays on screen when the body is hidden. A locked pane is exactly where
|
||||||
|
// arming is decided -- the body's button left with the body -- and the heading is where it has to be.
|
||||||
|
app.trigger_summary_arm = gtk_button_new_with_label("Arm");
|
||||||
|
gtk_widget_set_valign(app.trigger_summary_arm, GTK_ALIGN_CENTER);
|
||||||
|
gtk_widget_set_tooltip_text(app.trigger_summary_arm,
|
||||||
|
"arm or disarm the trigger; disarming freezes the view where it stands");
|
||||||
|
gtk_widget_set_visible(app.trigger_summary_arm, FALSE);
|
||||||
|
g_signal_connect(app.trigger_summary_arm, "clicked", G_CALLBACK(on_arm_clicked), &app);
|
||||||
|
gtk_box_append(GTK_BOX(head), app.trigger_summary_arm);
|
||||||
app.trigger_switch = gtk_switch_new();
|
app.trigger_switch = gtk_switch_new();
|
||||||
gtk_widget_set_valign(app.trigger_switch, GTK_ALIGN_CENTER);
|
gtk_widget_set_valign(app.trigger_switch, GTK_ALIGN_CENTER);
|
||||||
gtk_widget_set_tooltip_text(app.trigger_switch,
|
gtk_widget_set_tooltip_text(app.trigger_switch,
|
||||||
@@ -447,8 +721,30 @@ GtkWidget* build_trigger_panel(App& app) {
|
|||||||
g_signal_connect(app.trigger_switch, "notify::active", G_CALLBACK(on_panel_switch_toggled), &app);
|
g_signal_connect(app.trigger_switch, "notify::active", G_CALLBACK(on_panel_switch_toggled), &app);
|
||||||
gtk_box_append(GTK_BOX(head), app.trigger_switch);
|
gtk_box_append(GTK_BOX(head), app.trigger_switch);
|
||||||
|
|
||||||
|
// The locked pane's content: the settings as a definition list, so the answer to "what is this set to"
|
||||||
|
// is four short lines rather than a row of controls.
|
||||||
|
app.trigger_summary = gtk_grid_new();
|
||||||
|
gtk_grid_set_row_spacing(GTK_GRID(app.trigger_summary), 2);
|
||||||
|
gtk_grid_set_column_spacing(GTK_GRID(app.trigger_summary), 12);
|
||||||
|
gtk_widget_set_margin_top(app.trigger_summary, 6);
|
||||||
|
gtk_widget_set_margin_bottom(app.trigger_summary, 6);
|
||||||
|
gtk_widget_set_margin_start(app.trigger_summary, 8);
|
||||||
|
gtk_widget_set_margin_end(app.trigger_summary, 8);
|
||||||
|
for (int i = 0; i < App::kTriggerSummaryRows; ++i) {
|
||||||
|
app.trigger_summary_keys[i] = gtk_label_new("");
|
||||||
|
gtk_widget_add_css_class(app.trigger_summary_keys[i], "dim-label");
|
||||||
|
gtk_widget_set_halign(app.trigger_summary_keys[i], GTK_ALIGN_START);
|
||||||
|
app.trigger_summary_values[i] = gtk_label_new("");
|
||||||
|
gtk_widget_set_halign(app.trigger_summary_values[i], GTK_ALIGN_START);
|
||||||
|
gtk_label_set_ellipsize(GTK_LABEL(app.trigger_summary_values[i]), PANGO_ELLIPSIZE_END);
|
||||||
|
gtk_grid_attach(GTK_GRID(app.trigger_summary), app.trigger_summary_keys[i], 0, i, 1, 1);
|
||||||
|
gtk_grid_attach(GTK_GRID(app.trigger_summary), app.trigger_summary_values[i], 1, i, 1, 1);
|
||||||
|
}
|
||||||
|
gtk_widget_set_visible(app.trigger_summary, FALSE);
|
||||||
|
|
||||||
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
GtkWidget* outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
|
||||||
gtk_box_append(GTK_BOX(outer), head);
|
gtk_box_append(GTK_BOX(outer), head);
|
||||||
|
gtk_box_append(GTK_BOX(outer), app.trigger_summary);
|
||||||
gtk_box_append(GTK_BOX(outer), box);
|
gtk_box_append(GTK_BOX(outer), box);
|
||||||
return outer;
|
return outer;
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-1
@@ -37,6 +37,13 @@ GtkBuilder* ui_load(App& app, const char* resource) {
|
|||||||
/// the whole side panel. Scoped to `.udpscope-compact` so it cannot reach any other
|
/// the whole side panel. Scoped to `.udpscope-compact` so it cannot reach any other
|
||||||
/// button in the window.
|
/// button in the window.
|
||||||
void install_css() {
|
void install_css() {
|
||||||
|
// Idempotent, because it is no longer installed from one place: the source chooser runs before the
|
||||||
|
// application (it draws a menu built from the same `menu_button()` rows) and has to have the
|
||||||
|
// stylesheet before it shows them, while `on_activate()` still installs it for every run. A second
|
||||||
|
// provider would be a second copy of the same rules on the display.
|
||||||
|
static bool installed = false;
|
||||||
|
if (installed) return;
|
||||||
|
installed = true;
|
||||||
static const char kCss[] =
|
static const char kCss[] =
|
||||||
"row.udpscope-compact { min-height: 0; padding: 1px 0; }\n"
|
"row.udpscope-compact { min-height: 0; padding: 1px 0; }\n"
|
||||||
// A popover menu row: Adwaita gives a button a 34 px minimum height and 8 px of padding,
|
// A popover menu row: Adwaita gives a button a 34 px minimum height and 8 px of padding,
|
||||||
@@ -53,7 +60,36 @@ void install_css() {
|
|||||||
".udpscope-event-title { font-weight: bold; }\n"
|
".udpscope-event-title { font-weight: bold; }\n"
|
||||||
".udpscope-event-thumb { border: 1px solid alpha(currentColor, 0.15); border-radius: 4px; }\n"
|
".udpscope-event-thumb { border: 1px solid alpha(currentColor, 0.15); border-radius: 4px; }\n"
|
||||||
"row.udpscope-compact button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n"
|
"row.udpscope-compact button { min-height: 20px; min-width: 20px; padding: 0 3px; }\n"
|
||||||
"row.udpscope-compact checkbutton { min-height: 20px; }\n";
|
"row.udpscope-compact checkbutton { min-height: 20px; }\n"
|
||||||
|
// The guided tour. The dim is a half-transparent black rather than a theme colour: its whole
|
||||||
|
// job is to darken whatever is underneath, on a light theme and a dark one alike. The card
|
||||||
|
// needs its own background, or a box in a popover-less overlay is transparent and the text
|
||||||
|
// under it shows through.
|
||||||
|
".udpscope-tour-dim { background-color: rgba(0, 0, 0, 0.45); }\n"
|
||||||
|
".udpscope-tour-spot { border: 2px solid @accent_bg_color; border-radius: 8px; }\n"
|
||||||
|
".udpscope-tour-card { background-color: @window_bg_color; border: 1px solid @borders;"
|
||||||
|
" border-radius: 12px; padding: 12px; box-shadow: 0 3px 12px rgba(0, 0, 0, 0.45); }\n"
|
||||||
|
// The contextual-help "(i)": a *mark*, not a button. Adwaita's menu button is a 34x34 control
|
||||||
|
// (a 24 px floor plus 5 px of padding) with a 16 px glyph, which beside a heading reads as a
|
||||||
|
// second toolbar button. Measured on libadwaita 1.10: the outer `menubutton` node *and* its
|
||||||
|
// `button` subnode both carry a floor and padding, and a class selector on either alone leaves
|
||||||
|
// the other at 34 (`min-width` on the widget itself is ignored by `GtkMenuButton`'s measure, and
|
||||||
|
// so is a size request -- tried both). Beyond those two there is a third node, and getting its
|
||||||
|
// path wrong is silent: the button's own tree is `menubutton > button > box > image` (read off
|
||||||
|
// the CSS node dump), so the direct-grandchild form `> button > image` matches *nothing* and
|
||||||
|
// leaves the glyph at its natural 16 px -- which is the 16x16 the first version shipped and
|
||||||
|
// mistook for a floor. The glyph is reached through the `box`; trimming the outer node, the
|
||||||
|
// `button` subnode and the glyph together, and asking the glyph for 12 px with
|
||||||
|
// `-gtk-icon-size` (which `GtkImage` honours when it is `GTK_ICON_SIZE_INHERIT`, and which
|
||||||
|
// *scales* the drawing rather than merely clamping its box), is what brings the mark to
|
||||||
|
// **12x12**. There is no lower floor to find: the symbolic glyph renders at any request
|
||||||
|
// (measured down to 4 px), so 12 px is a legibility choice, and it is the size the rule always
|
||||||
|
// meant -- the first version already wrote 12 px, on a selector that never matched. That is
|
||||||
|
// the reading `hint=WxH` in the dump prints.
|
||||||
|
"menubutton.udpscope-hint { min-width: 0; min-height: 0; padding: 0; margin: 0; }\n"
|
||||||
|
"menubutton.udpscope-hint > button { min-width: 0; min-height: 0; padding: 0; }\n"
|
||||||
|
"menubutton.udpscope-hint > button > box > image { min-width: 12px; min-height: 12px;"
|
||||||
|
" -gtk-icon-size: 12px; }\n";
|
||||||
GtkCssProvider* p = gtk_css_provider_new();
|
GtkCssProvider* p = gtk_css_provider_new();
|
||||||
gtk_css_provider_load_from_string(p, kCss);
|
gtk_css_provider_load_from_string(p, kCss);
|
||||||
gtk_style_context_add_provider_for_display(gdk_display_get_default(),
|
gtk_style_context_add_provider_for_display(gdk_display_get_default(),
|
||||||
|
|||||||
Executable
+109
@@ -0,0 +1,109 @@
|
|||||||
|
#!/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, checks the structural rules below, checks the
|
||||||
|
# figures, 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, and it is worth being exact about why: it
|
||||||
|
# builds the RelaxNG schema locally with `xsltproc` and `mal-rng.xsl`, but the schema it
|
||||||
|
# generates includes the base grammar over the network, so on a machine where fetching is
|
||||||
|
# blocked it fails for that reason rather than for a fault in the pages.
|
||||||
|
#
|
||||||
|
# What that leaves unverified is *structure*, and that is not a small gap: three pages
|
||||||
|
# carrying a `<figure>` whose `<title>` was a sentence and which had no `<desc>` opened in
|
||||||
|
# nothing, while links, media, orphans, ids and the HTML render all passed. So the rules this
|
||||||
|
# project has actually needed are checked here. Add a rule when a page *teaches* one -- and do
|
||||||
|
# not add one you have not seen broken: a rule that fails a legal page is worse than no rule.
|
||||||
|
#
|
||||||
|
# Every XPath expression uses local-name() on purpose. A Mallard page declares
|
||||||
|
# `xmlns="http://projectmallard.org/1.0/"`, and XPath 1.0 has no default namespace, so
|
||||||
|
# `//figure` matches *nothing* and every rule below silently passes. Written naively, this
|
||||||
|
# check reported "ok" for the very defect it was written for.
|
||||||
|
#
|
||||||
|
# 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
|
||||||
|
|
||||||
|
failed=0
|
||||||
|
for page in "$dir"/*.page; do
|
||||||
|
while IFS='|' read -r what expr; do
|
||||||
|
[ -n "$what" ] || continue
|
||||||
|
n=$(xmllint --xpath "$expr" "$page" 2>/dev/null || echo "?")
|
||||||
|
if [ "$n" != "0" ]; then
|
||||||
|
printf ' %s: %s (%s)\n' "$(basename "$page")" "$what" "$n" >&2
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done <<'RULES'
|
||||||
|
a figure describes itself|count(//*[local-name()='figure'][not(*[local-name()='desc'])])
|
||||||
|
a section has a title|count(//*[local-name()='section'][not(*[local-name()='title'])])
|
||||||
|
info is the page's first child|count(/*[local-name()='page']/*[1][local-name()!='info'])
|
||||||
|
no text sits directly under page|count(/*[local-name()='page']/text()[normalize-space()])
|
||||||
|
a media names its src|count(//*[local-name()='media'][not(@src)])
|
||||||
|
RULES
|
||||||
|
done
|
||||||
|
|
||||||
|
# The figures, and this one is a *decoder's* rule rather than ours: a `bKGD` chunk is forbidden
|
||||||
|
# by the PNG specification on an image with an alpha channel, and `import` writes one because it
|
||||||
|
# records the root window's background. ImageMagick both writes and reads it, so a round trip
|
||||||
|
# through it proves nothing -- which is how three figures passed every check while WebKit
|
||||||
|
# refused them, showing a figure that never loaded and, over the `help:` URI, a page that spun
|
||||||
|
# forever. Only the chunks PNG allows for the colour type are accepted; a whitelist, because a
|
||||||
|
# decoder is one.
|
||||||
|
python3 - "$dir" <<'PY' || failed=1
|
||||||
|
import glob, os, struct, sys
|
||||||
|
seen = 0
|
||||||
|
for path in sorted(glob.glob(os.path.join(sys.argv[1], 'figures', '*.png'))):
|
||||||
|
seen += 1
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
if f.read(8) != b'\x89PNG\r\n\x1a\n':
|
||||||
|
print(f" {os.path.basename(path)}: not a PNG", file=sys.stderr)
|
||||||
|
continue
|
||||||
|
chunks = []
|
||||||
|
while True:
|
||||||
|
head = f.read(8)
|
||||||
|
if len(head) < 8:
|
||||||
|
break
|
||||||
|
length, kind = struct.unpack('>I4s', head)
|
||||||
|
f.read(length)
|
||||||
|
f.read(4)
|
||||||
|
chunks.append(kind)
|
||||||
|
# Colour type 4 (grey+alpha) and 6 (RGB+alpha) have an alpha channel; bKGD is not permitted
|
||||||
|
# on either. Anything else is left alone: this checks the rule a page was broken by.
|
||||||
|
colour_type = None
|
||||||
|
with open(path, 'rb') as f:
|
||||||
|
f.read(16)
|
||||||
|
colour_type = f.read(1)[0]
|
||||||
|
if colour_type in (4, 6) and b'bKGD' in chunks:
|
||||||
|
print(f" {os.path.basename(path)}: bKGD on an image with alpha (colour type {colour_type})"
|
||||||
|
" -- PNG forbids it and a strict decoder refuses the file", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
if seen == 0:
|
||||||
|
print(" no figures to check", file=sys.stderr)
|
||||||
|
PY
|
||||||
|
|
||||||
|
if [ "$failed" != 0 ]; then
|
||||||
|
echo "check_help: findings above" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'structure %s: ok; figures: ok\n' "$dir"
|
||||||
|
|
||||||
|
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"
|
||||||
Executable
+236
@@ -0,0 +1,236 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# SPDX-FileCopyrightText: 2026 Martino Ferrari
|
||||||
|
#
|
||||||
|
# Regenerates the screenshots the Mallard manual carries (help/C/<id>/figures/*.png).
|
||||||
|
#
|
||||||
|
# It runs udpscope headlessly under its own Xvfb, against tools/mock_udpstreamer.py, drives it
|
||||||
|
# into a state with the same `UDPSCOPE_*` probes the self-check uses, grabs the window with
|
||||||
|
# ImageMagick's `import`, and draws numbered annotation boxes on a borderless crop with
|
||||||
|
# `magick`.
|
||||||
|
#
|
||||||
|
# Two things about a headless box, both measured rather than assumed:
|
||||||
|
#
|
||||||
|
# * GTK4 picks the Vulkan renderer by default, and under Xvfb that surface is not readable
|
||||||
|
# back through X11 -- `import` sees a window with no pixels (standard deviation 0). Forcing
|
||||||
|
# the Cairo renderer (`GSK_RENDERER=cairo`, with `LIBGL_ALWAYS_SOFTWARE=1`) makes GTK paint
|
||||||
|
# with XPutImage, which `import` can read.
|
||||||
|
# * `xdotool search --name udpscope` finds nothing: GTK4 sets no WM_NAME on the X window it
|
||||||
|
# creates, so windows are found by their geometry, not their name.
|
||||||
|
#
|
||||||
|
# Box provenance: the sidebar width is read from the app's own probe dump (`side=NNNx`), so the
|
||||||
|
# side-panel box tracks the real widget; the header and status bands are the table in
|
||||||
|
# `window_boxes` below, each named where it is used, because the dump reports neither.
|
||||||
|
#
|
||||||
|
# Usage: tools/make-help-shots.sh [OUTPUT_DIR] (default help/C/<id>/figures)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd -P)
|
||||||
|
root=$(dirname -- "$here")
|
||||||
|
app=${UDPSCOPE_SHOTS_APP:-$root/build/udpscope}
|
||||||
|
mock=$here/mock_udpstreamer.py
|
||||||
|
help_id=org.udpscope.app
|
||||||
|
out=${1:-$root/help/C/$help_id/figures}
|
||||||
|
port=18391
|
||||||
|
|
||||||
|
for tool in Xvfb import magick xdotool python3; do
|
||||||
|
command -v "$tool" >/dev/null || { echo "make-help-shots: $tool is required" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
[ -x "$app" ] || { echo "make-help-shots: $app is missing; build it first" >&2; exit 1; }
|
||||||
|
[ -f "$mock" ] || { echo "make-help-shots: $mock is missing" >&2; exit 1; }
|
||||||
|
mkdir -p "$out"
|
||||||
|
|
||||||
|
# --- the window's own geometry, and the boxes it is annotated with -------------
|
||||||
|
# main-window.ui asks for 1280x800, and the sidebar is a fixed 320 px
|
||||||
|
# (sidebar-width-unit=px, min=max=320). The header bar is the window's titlebar and the status
|
||||||
|
# row sits at the foot of the plot column; neither reports a pixel height, so their bands are
|
||||||
|
# the third measurement -- a pixel scan of a grab, 51 px and 43 px on the current theme -- and
|
||||||
|
# are refreshed by re-measuring if the styling changes.
|
||||||
|
WIN_W=1280
|
||||||
|
WIN_H=800
|
||||||
|
HEADER_H=51
|
||||||
|
STATUS_H=43
|
||||||
|
PLOT_W=952
|
||||||
|
|
||||||
|
boxes_main="header bar:0:0:$WIN_W:$HEADER_H
|
||||||
|
side panel:SIDE_X:$HEADER_H:SIDE_W:706
|
||||||
|
plot:0:$HEADER_H:$PLOT_W:706
|
||||||
|
status bar:0:757:$PLOT_W:$STATUS_H"
|
||||||
|
boxes_trigger="event held, axis pinned to the event:0:757:$PLOT_W:$STATUS_H
|
||||||
|
the trigger marker at x = 0:0:$HEADER_H:$PLOT_W:706"
|
||||||
|
boxes_math="the graph editor:8:8:EW-24:EH-24"
|
||||||
|
|
||||||
|
display=
|
||||||
|
xvfb_pid=
|
||||||
|
app_pid=
|
||||||
|
mock_pid=
|
||||||
|
cleanup() {
|
||||||
|
[ -n "$app_pid" ] && kill "$app_pid" 2>/dev/null || true
|
||||||
|
[ -n "$mock_pid" ] && kill "$mock_pid" 2>/dev/null || true
|
||||||
|
[ -n "$xvfb_pid" ] && kill "$xvfb_pid" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# A display of our own, so nothing on the developer's screen is grabbed or disturbed.
|
||||||
|
for n in $(seq 210 240); do
|
||||||
|
if Xvfb ":$n" -screen 0 1600x1000x24 -nolisten tcp >/dev/null 2>&1 & then
|
||||||
|
xvfb_pid=$!
|
||||||
|
sleep 1
|
||||||
|
if kill -0 "$xvfb_pid" 2>/dev/null; then display=":$n"; break; fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ -n "$display" ] || { echo "make-help-shots: no free Xvfb display in :210..:240" >&2; exit 1; }
|
||||||
|
export DISPLAY="$display"
|
||||||
|
export GDK_BACKEND=x11 GSK_RENDERER=cairo LIBGL_ALWAYS_SOFTWARE=1
|
||||||
|
|
||||||
|
# A throwaway config dir: the app restores the last session for a source, and a restored
|
||||||
|
# layout would change the very arrangement the screenshot is meant to show.
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'cleanup; rm -rf "$tmp"' EXIT
|
||||||
|
export XDG_CONFIG_HOME="$tmp/config" XDG_CACHE_HOME="$tmp/cache" XDG_DATA_HOME="$tmp/data"
|
||||||
|
|
||||||
|
python3 "$mock" --port "$port" --rate 10000 --batch 100 >"$tmp/mock.log" 2>&1 &
|
||||||
|
mock_pid=$!
|
||||||
|
sleep 0.5
|
||||||
|
|
||||||
|
# The largest window that is not the 1600x1000 X root; that is the app's 1280x800 one (a math
|
||||||
|
# editor opened on top is smaller, and X may also expose unnamed GTK helper windows).
|
||||||
|
pick_window() {
|
||||||
|
local id geo w best= best_w=0
|
||||||
|
for id in $(xdotool search --onlyvisible "" 2>/dev/null); do
|
||||||
|
geo=$(xdotool getwindowgeometry "$id" 2>/dev/null | awk '/Geometry/{print $2}')
|
||||||
|
[ "$geo" = "1600x1000" ] && continue
|
||||||
|
w=${geo%x*}
|
||||||
|
case "$w" in ''|*[!0-9]*) continue;; esac
|
||||||
|
if [ "$w" -gt "$best_w" ]; then best=$id; best_w=$w; fi
|
||||||
|
done
|
||||||
|
[ -n "$best" ] && echo "$best"
|
||||||
|
}
|
||||||
|
|
||||||
|
# The math editor is a window of its own (a `GtkWindow`, presented on the main one), so it is
|
||||||
|
# the largest window that is neither the X root nor the app's 1280x800 one and that has a real
|
||||||
|
# size (X exposes 1x1 helper windows too).
|
||||||
|
pick_other_window() {
|
||||||
|
local id geo w best= best_w=0
|
||||||
|
for id in $(xdotool search --onlyvisible "" 2>/dev/null); do
|
||||||
|
geo=$(xdotool getwindowgeometry "$id" 2>/dev/null | awk '/Geometry/{print $2}')
|
||||||
|
case "$geo" in 1600x1000|"${WIN_W}x${WIN_H}") continue;; esac
|
||||||
|
w=${geo%x*}
|
||||||
|
case "$w" in ''|*[!0-9]*) continue;; esac
|
||||||
|
[ "$w" -le 50 ] && continue
|
||||||
|
if [ "$w" -gt "$best_w" ]; then best=$id; best_w=$w; fi
|
||||||
|
done
|
||||||
|
[ -n "$best" ] && echo "$best"
|
||||||
|
}
|
||||||
|
|
||||||
|
start_app() { # LOGFILE ENVS... ; extra args via APP_ARGS
|
||||||
|
local log=$1; shift
|
||||||
|
# shellcheck disable=SC2086
|
||||||
|
env "$@" "$app" --server 127.0.0.1 --port "$port" --buffer-mb 16 ${APP_ARGS:-} >"$log" 2>&1 &
|
||||||
|
app_pid=$!
|
||||||
|
}
|
||||||
|
|
||||||
|
stop_app() {
|
||||||
|
[ -n "$app_pid" ] && kill "$app_pid" 2>/dev/null || true
|
||||||
|
wait "$app_pid" 2>/dev/null || true
|
||||||
|
app_pid=
|
||||||
|
}
|
||||||
|
|
||||||
|
side_of_dump() { # LOGFILE -> the sidebar width the app reports, else the table's 320
|
||||||
|
local s
|
||||||
|
s=$(grep -o 'side=[0-9]*x' "$1" 2>/dev/null | head -1 | grep -o '[0-9]*' || true)
|
||||||
|
echo "${s:-320}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# annotate NAME RAW LOG BOXES W H -> the figure, with a numbered box per line of BOXES.
|
||||||
|
# A box is `label:x:y:w:h`; `SIDE_X`/`SIDE_W` are the sidebar's own numbers, `EW`/`EH` the
|
||||||
|
# grabbed window's width and height.
|
||||||
|
annotate() {
|
||||||
|
local name=$1 raw=$2 log=$3 boxes=$4 win_w=$5 win_h=$6
|
||||||
|
local side_w side_x i=0 draw=() line label x y w h
|
||||||
|
side_w=$(side_of_dump "$log")
|
||||||
|
side_x=$((win_w - side_w))
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] || continue
|
||||||
|
IFS=: read -r label x y w h <<<"$line"
|
||||||
|
x=${x/SIDE_X/$side_x}; w=${w/SIDE_W/$side_w}
|
||||||
|
w=${w/EW/$win_w}; h=${h/EH/$win_h}
|
||||||
|
i=$((i + 1))
|
||||||
|
draw+=(-stroke '#e01b24' -strokewidth 3 -fill none
|
||||||
|
-draw "rectangle $x,$y $((x + w)),$((y + h))"
|
||||||
|
-stroke none -fill '#e01b24'
|
||||||
|
-draw "circle $((x + 17)),$((y + 17)) $((x + 17)),$((y + 33))"
|
||||||
|
-fill white -stroke none -pointsize 22
|
||||||
|
-draw "text $((x + 10)),$((y + 25)) '$i'")
|
||||||
|
done <<<"$boxes"
|
||||||
|
magick "$raw" -crop "${win_w}x${win_h}+0+0" +repage "${draw[@]}" "$out/$name.png"
|
||||||
|
local sd
|
||||||
|
sd=$(magick identify -format '%[standard-deviation]' "$out/$name.png")
|
||||||
|
awk -v s="$sd" 'BEGIN{exit !(s+0 > 0)}' ||
|
||||||
|
{ echo "make-help-shots: the '$name' figure is blank (stddev=$sd)" >&2; return 1; }
|
||||||
|
echo "make-help-shots: wrote $out/$name.png ($(magick identify -format '%wx%h' "$out/$name.png"))"
|
||||||
|
|
||||||
|
# Print what each number marks, so the script is self-documenting in CI.
|
||||||
|
local n=0
|
||||||
|
while IFS= read -r line; do
|
||||||
|
[ -n "$line" ] || continue
|
||||||
|
n=$((n + 1))
|
||||||
|
printf ' %s = %s\n' "$n" "${line%%:*}"
|
||||||
|
done <<<"$boxes"
|
||||||
|
}
|
||||||
|
|
||||||
|
# grab NAME LOG BOXES [PICKER] -- grab the picked window, fail if it is blank, stop the app,
|
||||||
|
# annotate it at its own size.
|
||||||
|
grab() {
|
||||||
|
local name=$1 log=$2 boxes=$3 picker=${4:-pick_window} win geo win_w win_h raw sd
|
||||||
|
win=$($picker)
|
||||||
|
[ -n "$win" ] || { echo "make-help-shots: no window for '$name'" >&2; stop_app; return 1; }
|
||||||
|
geo=$(xdotool getwindowgeometry "$win" 2>/dev/null | awk '/Geometry/{print $2}')
|
||||||
|
win_w=${geo%x*}; win_h=${geo#*x}
|
||||||
|
raw="$tmp/$name.raw.png"
|
||||||
|
import -window "$win" "$raw"
|
||||||
|
sd=$(magick identify -format '%[standard-deviation]' "$raw")
|
||||||
|
awk -v s="$sd" 'BEGIN{exit !(s+0 > 0)}' ||
|
||||||
|
{ echo "make-help-shots: '$name' grabbed a blank window (stddev=$sd)" >&2; stop_app; return 1; }
|
||||||
|
stop_app
|
||||||
|
annotate "$name" "$raw" "$log" "$boxes" "$win_w" "$win_h"
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- scene 1: the live window -------------------------------------------------
|
||||||
|
echo "make-help-shots: scene 'main-window'"
|
||||||
|
APP_ARGS="--plots 3" start_app "$tmp/main-window.log" UDPSCOPE_ENABLE_ALL_MS=2500
|
||||||
|
sleep 8
|
||||||
|
grab main-window "$tmp/main-window.log" "$boxes_main"
|
||||||
|
|
||||||
|
# --- scene 2: a held capture --------------------------------------------------
|
||||||
|
echo "make-help-shots: scene 'trigger-capture'"
|
||||||
|
APP_ARGS="--plots 2" start_app "$tmp/trigger-capture.log" \
|
||||||
|
UDPSCOPE_ENABLE_ALL_MS=1200 UDPSCOPE_ARM_TRIGGER_MS=1500
|
||||||
|
sleep 9
|
||||||
|
grab trigger-capture "$tmp/trigger-capture.log" "$boxes_trigger"
|
||||||
|
|
||||||
|
# --- scene 3: the math editor -------------------------------------------------
|
||||||
|
# The editor is a window of its own, presented on the main one, and `math_probe`
|
||||||
|
# (`UDPSCOPE_PROBE_MATH_MS`) opens it -- "editor opened" in its log is the reading. No clicks
|
||||||
|
# are needed: the probe presents it and reopens it after a structural edit, so by the time the
|
||||||
|
# wait ends it is up. It is grabbed on its own rather than through the main window, which is
|
||||||
|
# the whole point of the figure.
|
||||||
|
echo "make-help-shots: scene 'math-editor'"
|
||||||
|
APP_ARGS="--plots 1" start_app "$tmp/math-editor.log" \
|
||||||
|
UDPSCOPE_ENABLE_ALL_MS=1500 UDPSCOPE_PROBE_MATH_MS=2500
|
||||||
|
sleep 10
|
||||||
|
grab math-editor "$tmp/math-editor.log" "$boxes_math" pick_other_window
|
||||||
|
|
||||||
|
# The last step, and it is not cosmetic: `import` records the root window's background, so the
|
||||||
|
# PNG leaves ImageMagick with a `bKGD` chunk -- forbidden by the PNG specification on an image
|
||||||
|
# with an alpha channel (colour types 4 and 6), which these are. ImageMagick writes it and reads
|
||||||
|
# it back happily, so a round-trip through `magick` proves nothing; a stricter decoder refuses
|
||||||
|
# the file. In Yelp that showed as a figure that never loaded, and over the `help:` URI as a page
|
||||||
|
# that spun forever, because the load event never fired. Normalise every figure to the chunks a
|
||||||
|
# decoder is guaranteed to accept, whatever the grab produced.
|
||||||
|
for f in "$out"/*.png; do
|
||||||
|
magick "$f" -strip -define png:exclude-chunk=bKGD,cHRM,tIME,tEXt,tXMP,exif \
|
||||||
|
-define png:compression-level=9 "$f"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "make-help-shots: done"
|
||||||
@@ -8,8 +8,10 @@ once and DATA packets to whoever connected. Publish mode is Accumulate, so each
|
|||||||
packet carries `--batch` consecutive samples per (scalar) signal -- the shape the
|
packet carries `--batch` consecutive samples per (scalar) signal -- the shape the
|
||||||
viewer's per-sample path expects.
|
viewer's per-sample path expects.
|
||||||
|
|
||||||
Signals: `time` (the stream's own clock, seconds), `sine`, `square`, `ramp`.
|
Signals: `time` (the stream's own clock, seconds), `sine`, `square`, `ramp`,
|
||||||
Everything is float64 and unquantised.
|
`noise`. Everything is float64 and unquantised. `square` is band-limited by
|
||||||
|
default: its edges are the truncated Fourier series of a step, so each one
|
||||||
|
overshoots and rings (`--square-harmonics 0` gives the ideal +/-1 step).
|
||||||
|
|
||||||
./tools/mock_udpstreamer.py --port 8000 --rate 10000 --batch 100
|
./tools/mock_udpstreamer.py --port 8000 --rate 10000 --batch 100
|
||||||
"""
|
"""
|
||||||
@@ -85,6 +87,7 @@ def build_data(
|
|||||||
spike_every=0.0,
|
spike_every=0.0,
|
||||||
spike_amp=0.0,
|
spike_amp=0.0,
|
||||||
spike_width=0,
|
spike_width=0,
|
||||||
|
square_harmonics=30,
|
||||||
):
|
):
|
||||||
stamps = [t0 + i * dt for i in range(batch)]
|
stamps = [t0 + i * dt for i in range(batch)]
|
||||||
# `time_spike` models a device whose clock glitches for one packet: the stamps jump
|
# `time_spike` models a device whose clock glitches for one packet: the stamps jump
|
||||||
@@ -116,9 +119,33 @@ def build_data(
|
|||||||
hit = into < (spike_width * dt)
|
hit = into < (spike_width * dt)
|
||||||
sign = np.where((period.astype(np.int64) & 1) == 0, 1.0, -1.0)
|
sign = np.where((period.astype(np.int64) & 1) == 0, 1.0, -1.0)
|
||||||
sine = np.array(sine) + np.where(hit, sign * spike_amp, 0.0)
|
sine = np.array(sine) + np.where(hit, sign * spike_amp, 0.0)
|
||||||
square = [
|
# A real square is band-limited: an edge is not a step but an overshoot followed by a
|
||||||
1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0 for t in stamps
|
# decaying ring. The truncated Fourier series is exactly that shape, and it is
|
||||||
]
|
# deterministic -- unlike the interferer -- so a probe can still assert the spacing and the
|
||||||
|
# count of the events it finds. Only odd harmonics, so the sum keeps the ideal wave's edge
|
||||||
|
# instants (it passes through zero at the same crossing) and its polarity: the trigger's
|
||||||
|
# level-0 edges, the glitch's inverted pulse widths and the buffer search's hits all land
|
||||||
|
# where they did on the step. What moves is the extremes: 30 harmonics overshoot by the
|
||||||
|
# Gibbs constant, about 9% of the 2 V swing, so y = +/-1.179 rather than +/-1 -- which is
|
||||||
|
# why a probe asserting an exact extreme or an exact step width asks for
|
||||||
|
# `--square-harmonics 0`, the ideal +/-1 step that was the only shape here before.
|
||||||
|
if square_harmonics > 0:
|
||||||
|
# The same series as a per-sample Python sum costs ~15 sin() calls a sample, and at the
|
||||||
|
# fast mock's rates that makes the *producer* the bottleneck: it falls behind its own
|
||||||
|
# declared pace, and a reading that scales with the retained span (the noise probe's
|
||||||
|
# crossing counts) then moves for a reason that has nothing to do with the signal.
|
||||||
|
t_arr = np.asarray(stamps)
|
||||||
|
harmonics = np.arange(1.0, float(square_harmonics) + 1.0, 2.0)
|
||||||
|
square = (
|
||||||
|
(4.0 / math.pi)
|
||||||
|
* np.sin(2.0 * math.pi * harmonics[:, None] * (freq * 0.5) * t_arr[None, :])
|
||||||
|
/ harmonics[:, None]
|
||||||
|
).sum(axis=0)
|
||||||
|
else:
|
||||||
|
square = [
|
||||||
|
1.0 if math.sin(2.0 * math.pi * freq * 0.5 * t) >= 0.0 else -1.0
|
||||||
|
for t in stamps
|
||||||
|
]
|
||||||
ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]
|
ramp = [(freq * t) % 1.0 * 2.0 - 1.0 for t in stamps]
|
||||||
noisy = np.sin(100 * math.pi * np.array(stamps) + 0.5) + np.random.normal(
|
noisy = np.sin(100 * math.pi * np.array(stamps) + 0.5) + np.random.normal(
|
||||||
0, 0.5, batch
|
0, 0.5, batch
|
||||||
@@ -207,6 +234,19 @@ def main():
|
|||||||
"what the exact probe assertions (peak to peak = 2, top = 2/pi) are read against; "
|
"what the exact probe assertions (peak to peak = 2, top = 2/pi) are read against; "
|
||||||
"0.1 is the figure the measurement hysteresis is sized against",
|
"0.1 is the figure the measurement hysteresis is sized against",
|
||||||
)
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--square-harmonics",
|
||||||
|
type=int,
|
||||||
|
default=30,
|
||||||
|
help="odd harmonics of `square`'s Fourier series: a real square is band-limited, so "
|
||||||
|
"each edge overshoots by the Gibbs constant (about 9%% of the 2 V swing at 30 "
|
||||||
|
"harmonics) and then rings -- the shape a crossing count, a pulse-width test or a "
|
||||||
|
"search has to survive. The odd harmonics keep the ideal wave's edge instants and "
|
||||||
|
"its polarity, so the level-0 trigger, the search and the glitch's inverted pulse "
|
||||||
|
"still read where they did on the step; the extremes move to +/-1.179. 0 (or "
|
||||||
|
"negative) = the ideal +/-1 step, which is what an assertion about an exact extreme "
|
||||||
|
"or an exact width asks for",
|
||||||
|
)
|
||||||
ap.add_argument(
|
ap.add_argument(
|
||||||
"--clock-jitter",
|
"--clock-jitter",
|
||||||
type=float,
|
type=float,
|
||||||
@@ -457,6 +497,7 @@ def main():
|
|||||||
args.spike_every,
|
args.spike_every,
|
||||||
args.spike_amp,
|
args.spike_amp,
|
||||||
args.spike_width,
|
args.spike_width,
|
||||||
|
args.square_harmonics,
|
||||||
)
|
)
|
||||||
burst = max(1, args.drop_burst)
|
burst = max(1, args.drop_burst)
|
||||||
if args.drop_every > 0 and drop_left == 0 and counter % args.drop_every == 0:
|
if args.drop_every > 0 and drop_left == 0 and counter % args.drop_every == 0:
|
||||||
|
|||||||
Reference in new issue
Block a user