26 Commits
Author SHA1 Message Date
Martino Ferrari e83ad86b8f measure: top/bottom/amplitude read the flat levels, not the extremes
A bench scope separates Vtop/Vbase from Vmax/Vmin because a band-limited
edge overshoots and rings: the mock's `square` at 30 harmonics reaches
±1.179 where its plateau is ±1. On the rippled square the old readings were
a property of the edges rather than of the waveform -- `top` 0.986496,
`amplitude` 1.179350 -- and `amplitude` reported the overshoot.

`top`, `bottom` and `amplitude` are now the flat levels and their delta.
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 holding at least half the densest one's buckets --
the outermost of those, which is what makes a waveform with no plateau read
its peak -- and the exact mean of the samples of the buckets within 5 % of
the window's peak-to-peak of it. 5 % is deliberately under the ~9 %-of-swing
Gibbs overshoot. `amplitude` is `top - bottom` (a scope's Vamplitude), not
the half-swing it was.

Measured, fast mock, 2 s window, harmonics 0 -> 30: top 1.000000 ->
0.999983, bottom -1.000000 -> -0.999983, amplitude 2.000000 -> 1.999966,
while `peak to peak` keeps the extremes (2.000000 -> 2.358701). On the 1 Hz
sine top is 0.957988 (it was 0.636620, the half-wave mean; a sine has no
plateau, so the densest band is at its peak) with sd 0.0014 over a scrolling
window, and `peak to peak` is still exactly 2.

No raw pass and no new fetch: the histogram is the same fetched bucket list
read a second time, gated on a pane that holds one of the three types.
`raw` is unchanged (0.1% clean, 2.1% with the --noise 0.1 interferer,
against 0.1%/2.1% before) for 0.44 -> 0.50 ms and 0.52 -> 0.65 ms a pass.
A window the pyramid cannot summarize -- an hf-filtered walk, or one shorter
than a bucket -- keeps the old readings: the mean of each side of the split,
and the window's peak to peak for amplitude.

Docs: the flat-level invariant (and the two 2/pi assertions it replaces),
the probe readings, the manual's measurements page and the README.
2026-10-15 01:19:33 +02:00
Martino Ferrari afd2a6cdc1 mock: the square's edges ring, and the one probe that wants a step asks for it
The square was the mock's only ideal step, which is the one shape a real producer
does not put on the wire. It is now the truncated Fourier series of the step --
`--square-harmonics N` odd harmonics, default 30 -- so each edge overshoots by the
Gibbs constant (about 9% of the 2 V swing, y = +/-1.179) and then rings, and
`--square-harmonics 0` is the ideal +/-1 step as before.

Odd harmonics only, so the series keeps the ideal wave's edge instants and its
polarity: the level-0 trigger, the glitch's inverted pulse and the buffer search
land where they did. Re-run in pairs (`--square-harmonics 0` against the default),
search, condition legs 0/1, trigrenum, disarm, trig trace, measures and events all
read exactly what they read before, with only `t0` moving. The reading that moved
is the noise probe's group-delay leg, whose `want` is the analytic `tau*ln2` of a
step: a band-limited edge reads `delay=0.002770 ratio=1.255`, the same figure on
every run. That recipe now asks for the ideal step, so its documented number stays
exact; probe-readings says why.

The series is vectorised: as a per-sample Python sum it cost ~15 sin() calls a
sample, which at the fast mock's 1.3 MS/s made the producer the bottleneck and
halved the rate the app saw (the noise probe's span-dependent counts read 22
against 48). Vectorised it adds ~0.5 ms to a 2000-sample packet, so the fast mock
runs at about nine tenths of its declared pace and those counts read within a few
percent.
2026-10-15 00:45:05 +02:00
Martino Ferrari 41cd8e5f38 help: recommend the system-wide install and record the location trap
A Mallard set under $XDG_DATA_HOME/help is one Yelp cannot draw the figures
of: its sandbox exposes the system help directories but not the user's data
directory, and each figure is a separate file the page references. The pages
open without their images, and through the app's help: URI the viewer spins
for ever because the failed image fetch means the page's load event never
fires -- which reads as a navigation failure, not a missing resource.

The alert and the manual both recommended install-help-user as the no-root
route, so they led users into exactly that case. Name the system-wide install
first and state the user-local caveat where the advice is given, and record
the finding in the GTK traps invariant so the next figure that will not draw
is not chased as a markup or a file problem.
2026-10-15 00:17:38 +02:00
Martino Ferrari 6138e6bdce fix: the figures carried a chunk WebKit refuses, which is why a page never loaded
The user's report was precise and I misread it twice: opening trigger.page as a *file* rendered the
page with no image, while opening it through the app's `help:` URI spun forever. Both are one cause.
The three figures carried a `bKGD` chunk -- and the PNG specification does not permit `bKGD` on an
image with an alpha channel, which all three are (colour type 6). ImageMagick both writes and reads
it, so my "decodes fully" check proved nothing: I tested with a decoder more permissive than the one
that has to load the file. WebKit rejects it, so the image never appears; over `help:` the fetch
never completes either, so the page's load event never fires and Yelp's spinner never stops.

It came from `import`, which records the root window's background. Every figure now carries only the
chunks a decoder is guaranteed to accept (IHDR/IDAT/IEND), and `tools/make-help-shots.sh` normalises
its output at the end so a re-shoot cannot reintroduce it -- with the reason written where the next
reader will be, since "ImageMagick reads it" is exactly the trap.

`tools/check_help.sh` gains the rule, because the four yelp-check subcommands and the structural
rules are all blind to a figure no decoder will accept: a `bKGD` on colour type 4 or 6 is a finding,
checked directly rather than through a permissive tool. It is a whitelist because a decoder is one.

Verified: the rule fires on the committed figures (colour type 6, bKGD present) and passes on the
fixed ones; the figures still decode and are still 1280x800 / 938x420; check_help.sh is green on the
source and installed sets; make -C build test-help passes.
2026-10-15 00:01:52 +02:00
Martino Ferrari fe499bfe41 help: the search block's (i) on its header, a 12 px mark, and a real search page 2026-10-14 23:29:00 +02:00
Martino Ferrari d549223889 fix: the guided tour selects the page a stop's target lives on
A stop whose target is on another page of a stack was entered without
switching to that page, so the spotlight aimed at a widget that was not
on screen. A stop now declares the page it stands on (`TourStop::page`)
and the tour selects it as the step is entered -- the window's trigger
and signals stops put the sidebar on Signals, the chooser's tabs stop
shows the Waveforms page and stepping off it brings Sources back.

The stop filter moved into `tour_begin()` and now runs on
`gtk_widget_get_mapped()`: GTK 4.24's `is_visible()` ignores
`child_visible`, so it kept a stop on another page *and* one in a
collapsed pane alike. A wrong page is kept and selected; a collapsed
pane, whose stack is unmapped too, is still dropped.
2026-10-14 23:01:43 +02:00
Martino Ferrari 829176248a help: a tinier (i), hints beside their controls, and the tour starting in the chooser
The contextual help becomes the affordance the ask names: a plain (i)
(`dialog-information-symbolic`) at **16x16**, and one that sits beside the
control it explains rather than only in a pane's heading.

- The mark: `GtkMenuButton` ignores `min-width` on the widget and a size
  request, so the size is made in `install_css()` on all three nodes (outer
  `menubutton`, its `button` subnode, the glyph). Measured 16x16 against the
  default 34x34; the dump prints it as `hint=WxH`, measured with
  `gtk_widget_compute_bounds()` on the first *visible* hint.
- More sites: `events_record` (the "record events" switch in the trigger
  panel), `trigger_advanced` (the pre-trigger share row), `signals_config`
  (the signals pane's hint line, wrapped in a box so a hint can join it).
  Hints join as siblings, so none can cover a switch, pencil or dropdown.
- The source chooser gets its own layer -- `chooser_sources` (a new heading
  above the saved-source list) and `chooser_waveform` (the waveforms action
  row) -- built with the one `context_help_button()` both windows use and
  re-synced by its Help menu's toggle. The form's `AdwEntryRow`s cannot host a
  sibling hint, so none is invented for the host/port fields.
- The tour is one implementation over two windows: `ui_impl::TourView` plus a
  list of `TourStop`s; each window keeps its own table. A walk that passes its
  last stop calls `on_done`, and the chooser's sets `Config::tour_pending`, so
  a first run is one tour that crosses from the chooser into the app. The
  chooser's tour auto-starts from `settings.tour_seen` exactly as the window's
  does, and its "Guided tour" row now starts *it* instead of only requesting
  the window's.
- Probes: `UDPSCOPE_PROBE_CHOOSER_TOUR=1` / `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`,
  the hint size in the `help layer:`/`[helpui] contextual on:` lines, and the
  chooser's hints in its `chooser ui:` line. Docs updated in
  invariants-gtk-and-chrome.md, probe-readings.md and verification.md.
2026-10-14 22:42:38 +02:00
Martino Ferrari 395c9caec3 help: send readers to the install targets, in the alert and the manual
The failure alert told people to run `cmake --install build --prefix ~/.local`;
that still works, but `cmake --build build --target install-help-user` is the
form that needs no prefix guessing and prints the path it wrote, so the alert
names it and stays its former length. The manual gains a short "Installing
this manual" section on the overview page naming both targets -- the no-root
user one and the prefix-driven one for a system-wide or packaged install,
which the caller wraps in sudo -- and README and the GTK invariants doc follow
the alert's new command.
2026-10-14 22:30:07 +02:00
Martino Ferrari 669db6df9a build: install the manual alone, into the prefix or the user datadir
The manual is the only thing udpscope installs, but the only way to get it
where `help:` resolves was a whole `cmake --install`. Two opt-in targets
now install just the set, each printing where it wrote:

- install-help goes through the install machinery into CMAKE_INSTALL_PREFIX
  (reachable via the new Help component), so a packager or a system-wide
  user points it at /usr and wraps it in sudo;
- install-help-user resolves the user data directory the way the app does
  ($XDG_DATA_HOME, else $HOME/.local/share) and copies there without root.

Neither is built by default and neither changes what `cmake --install`
does; the install() rule keeps its destination.
2026-10-14 22:28:23 +02:00
Martino Ferrari bb62b8178c fix: place the guided tour's card from its allocation, and count GTK warnings
The tour measured its card with a height-for-width request whose for_size
came from the natural width minus the margins the tour sets for positioning.
A measure reports margins *included*, so that for_size landed a margin short
of the minimum the box answers with and GTK warned once per frame:

  Trying to measure GtkBox 0x... for width of N, but it needs at least M

(about 9/s for as long as the tour was up). The card now takes its size from
gtk_widget_compute_bounds() once a frame has run -- its allocation rectangle,
not gtk_widget_get_width(), which is only the content box -- and falls back
to a measure with no for_size at all only on a frame with nothing allocated.

GTK is built with structured logging, so its messages reach the log writer
and never the handler API; no existing probe can read one. The exit dump now
counts them (`gtk warnings:`, must read 0) through a writer function chained
to g_log_writer_default(), which leaves stderr unchanged.
2026-10-14 17:38:25 +02:00
Martino Ferrari d8a79b96ba help: default the contextual layer off, and carry the help into the chooser
The contextual help was on by default, so a first run met a window with a "?"
beside every surface it had not asked to have explained; `contextual_help` now
defaults to false and the layer is asked for from the Help menu, from the
chooser's new help menu, or with Ctrl+H.

The source chooser runs before `App` exists, which makes it the window a user
who does not know how to start is looking at, so it now carries the same three
help affordances the window's Help view does: Documentation (the manual is
another process and needs nothing of the app), Enable contextual help (a row
whose check *is* the setting, written to the file the app reads a moment
later), and Guided tour. The tour is drawn on the app's own window, so the
chooser can only record the wish: it sets `Config::tour_pending` -- the value
`main()` owns and hands to both `choose_source()` and `run_ui()` -- and
`on_activate()` starts it once the window is up and laid out, through its own
callback so the readings can tell a request from the first-run auto-start.
The rows are the window's own `menu_button()`/`menu_toggle()`; `install_css()`
therefore runs from `choose_source()` (it is idempotent now) so the popover
gets the `.udpscope-menu` rules before it is shown.

Opening the manual could also fail with a message that blamed the handler
whatever was missing. `help_installed_path()` now resolves `help:<id>` the way
the specification does and the failure alert says which of the two it is: with
the set installed it names the viewer, without it it names the identifier, the
expected installed path and the install command. `show_help()` gained an
optional reporter, which `UDPSCOPE_PROBE_HELP_MS` uses for a new reading --
`[help] launch attempted= done= ok= error='' installed=` -- since a successful
launch leaves no window of ours to observe.

Tested: clean --clean-first build, three warnings as documented; test-ui 13/13
and test-help pass; no Gtk-CRITICAL in the help, helpui and chooser runs.
2026-10-14 17:22:03 +02:00
Martino Ferrari e140642ed0 fix: the manual's figures carry a description, and the check that would have caught them
Three pages opened in nothing in Yelp -- overview (twice), math, trigger -- while every check
passed. They are exactly the pages whose <figure> had a sentence for a <title> and no <desc>:
a Mallard <figure> is title?, desc?, media, and the page is refused without the description. The
idiomatic shape, as the shipped sets on this machine write it, moves the sentence into <desc> and
leaves the <title> a name. Note trigger-capture.png is referenced from both overview and trigger,
which is why those two pages are the ones that were noticed.

The gap was in the check, not in its reach: tools/check_help.sh ran links/media/orphans/ids and an
HTML render, and all four are silent about a page that is well linked, well parsed and
unrenderable. `yelp-check validate` is the one that would have caught it, and it cannot run here:
it generates the RelaxNG schema with xsltproc, but the schema includes the base grammar over the
network and this machine's libxml cannot fetch. So the structural rules this project has actually
needed are checked with xmllint's XPath, offline, each written after a page needed it.

One trap, paid for while writing it: a Mallard page declares
xmlns="http://projectmallard.org/1.0/", XPath 1.0 has no default namespace, so `//figure` matches
nothing and every rule passes -- the first version of this check reported "ok" for the very defect
it was written for. Every expression uses local-name() for that reason, and the script says so
where the next reader will be.

Verified: on the committed pages the rules exit 1 and name the three figures, which is the
failing-before case kept as the regression test; on the fixed pages all five rules pass and
yelp-build renders all 15; make -C build test-help goes from red to green.
2026-10-14 17:15:31 +02:00
Martino Ferrari 6fe1b22524 chore: drop the dead help window's .ui with the window it built
`help_window.cpp`'s paged window went with the manual (c1460a6), leaving `src/ui/help.ui` a builder
file that nothing loads and nothing references: its only mention was the bundle manifest beside the
icons. Removed both. That is also the reading `test-ui` now reports -- 13 files, 13 ok -- and no
document stated the old count, so nothing else had to move.

Verified on the integrated tree: clean build --clean-first, 0 errors and exactly the three documented
warnings (the two MathNode missing-initializers and the icons' 12352-byte overlength string); test-ui
13 ok, 0 failed; test-help renders all 15 pages; and two headless probe runs report 0 Gtk-CRITICAL:

    [help] topics=14 index=1 figures=3 ... shortcuts: app=14 plot=14 tables=28 ui=28 ui_accel=21
           ui_icon=7 ui_bare=0 ... closed ok
    [helpui] auto_started=1 visible=1 steps=5 step=0 (tour_seen already set)
    [helpui] contextual on: hints=8 ids={events plot status cursors measures trigger signals window}
    [helpui] main view: rows=7 help_row=1

and the trigger pane's compact Arm/Disarm -- the feature from 620473a -- still reads
`visible=1 sensitive=1 width=51 label=Disarm` with `panel_width=-1` while the pane is locked.
2026-10-14 17:06:06 +02:00
Martino Ferrari 1baa58b8fd feat: the help is a submenu, with a contextual-help layer and a guided tour
The hamburger menu's help rows were three flat entries and a window. They are a **`Help` submenu**
now, which is where every GNOME menu keeps them, with the four things a first run cannot find:

  - Keyboard shortcuts   Ctrl+/   (the existing overview, `show_shortcuts()`)
  - Documentation        F1      (the existing manual, `show_help()`)
  - Guided tour          Ctrl+T  (new)
  - Enable contextual help        (new, a stateful row: its check *is* the setting)

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 the pages of a `GtkStack`, not a rebuild of the box and not a swap of
the popover's child: the main view holds `app.layout_toggle`, which the tick's `sync_layout_editor()`
and the `L` accel read, so rebuilding would leave that pointer on a destroyed widget, and a swapped
child would leave the view that is not showing without an owner.

**The contextual help is one table and a "?" per surface** (`src/ui_context_help.cpp`): 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. Each row names the *heading box* its
button joins -- a sibling of the title, never an overlay child on a corner, which is what would cover
the switch or the [edit] button already there -- so adding a surface is a row and a widget pointer.
Off means *gone*, not greyed: the buttons are removed, and they are found by a data key rather than a
list of pointers because the math editor's heading is built afresh on every opening and a list would
go stale one dialog later. `contextual_help` and `tour_seen` are keys of `settings.toml`, written
through the same `Settings`/`load_settings()`/`save_settings()` the Preferences dialog uses, whose own
rows start from `load_settings()` and so carry them through untouched.

**The guided tour is five steps on a `GtkOverlay`** above the window's content: the plot, the side
panel, the trigger, the signals and the status line. There is no popover in it and therefore no pointer
grab to leak -- four dim rectangles around the hole are what make it modal -- and the probe asserts
that as a reading (`controllers=` against the count taken before it opened) rather than as a claim.
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, because a frame that
lands between two layout passes would otherwise empty it and make the spotlight jump. `Skip`, `Finish`
and `Escape` all hide every child and leave nothing behind.

A first run gets the tour once, decided by `tour_seen` and not by "the settings file did not exist" (a
user who changed the theme has a file and has still never seen it), and the flag is written when the
tour *starts* rather than when it finishes: a user who skips it in the first second has still seen it.

`UDPSCOPE_PROBE_HELPUI_MS` drives all of it -- the auto-start, the menu's seven rows and the
submenu's five, the toggle's effect read as a count of affordances on either side of it, and the tour
started, stepped forwards and back and dismissed -- and the exit dump gains `help layer:` and
`help figures:`, the latter being the ten surfaces' window-coordinate rectangles that the manual's
annotated screenshots crop their boxes from. Two of them are what the D-Bus-free headless run can
still not decide, and both are limitations rather than defects: export carries no "?" (it is a menu
action with no surface of its own, and a popover inside the menu's popover is the nested-popover trap
the invariants already warn about), and the tour card cannot miss its target when the target is the
whole content -- which is what the plot is at the window's 756 px minimum, where the split view
overlays the side panel instead of putting it beside the plot. The probe prints both rectangles
rather than asserting a placement, so that case reads as the geometry it is.

Also fixed while it was found: `gtk_widget_measure()` includes the widget's own margins (measured: a
280 px box with `margin-start 453` reports 733), so a measure that is used to *place* the widget is a
feedback loop -- that is what made the tour card walk between the corner and the target from frame to
frame.
2026-10-14 17:02:29 +02:00
Martino Ferrari c1460a6977 feat: the manual is a Mallard set opened in Yelp, not a GResource in the binary
The help was 14 Pango-markup `.txt` bodies in a second GResource, drawn into a paged `GtkWindow`
built in `help_window.cpp`. That made F1 a viewer of ours with no search index, no cross-references
and no translation, and it kept the manual in the *binary* when every other desktop application's is
a document on disk. It is now a real Mallard set:

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

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

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

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

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

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

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

Docs: the manual's build/install/open story in `build-and-run.md` and README; the warning baseline
in `verification.md`/CLAUDE.md; the help bullets in `invariants-gtk-and-chrome.md`; the `[help]`
line in `probe-readings.md`.
2026-10-14 16:49:30 +02:00
Martino Ferrari 620473a5be feat: the locked trigger pane carries Arm/Disarm, and one function states both arm buttons
The compact line -- the pane's locked, "what is it set to" mode -- showed the arm state in words and gave
no way to change it, so arming from there meant unfolding the panel first, which is the thing the lock
exists to avoid. The control is built into the pane's *heading*, the row that stays on screen when the
body goes, and it is wired to the same `on_arm_clicked` the body's button uses, so both do one thing.

Its label, sensitivity and visibility are stated by `sync_arm_buttons()`, called from the four places any of
its inputs can move: the click, the mode toggle, the curator's per-tick re-read (a single-shot latch disarms
by itself) and `apply_pane_lock()`. The label comes from the producer's `trigger_armed()` atomic, the other
two from the toolbar toggle that owns `enabled`, so no caller needs the model lock. Visibility is
`enabled && locked`: with trigger mode off the line beside it already reads "stream (trigger off)" and there
is no engine to arm, and with the pane open the body's own button is the one on screen. Never both -- two
arm buttons would be two controls for one state.

Verified with the arm probe, which locks the pane the way a user does (the [edit] click):

    compact arm with the trigger off: visible=0; with the trigger on and the pane locked:
      visible=1 sensitive=1 width=51 label=Disarm
    [udpscope] compact arm probe: locked=1 enabled=1 own=1 effective=1 width=51 panel_width=-1 label=Disarm

`panel_width=-1` is the "not both" assertion: the body's button is not on screen while the compact one is.
The width cost a defect to read. The control is created hidden and shown for the first time inside the
handler, so a same-tick `gtk_widget_get_width()` reads 0 while `gtk_widget_is_visible()` already says 1 --
and the body button's *stale* allocation (270 px, from when the body was still up) reads as a live one,
because `gtk_widget_get_visible()` is the widget's own flag and stays true under a hidden parent. The
reading is therefore taken one frame later, by a step of its own, and the control is compared with
`is_visible()`. Both defects are in the invariants, because the next control read this way will meet them.

0 Gtk-CRITICAL; clean build with the documented four warnings (the help resource re-measured); test-ui
14/14. Docs: the rule and both readings in `invariants-trigger-and-pinning.md`, the probe's entry in
`probe-readings.md`, and the user-facing sentence in `src/help/08-trigger.txt`.
2026-10-14 16:18:24 +02:00
Martino Ferrari 908286ee89 feat: the export writes an MDF 3.30 file, so a window loads in ScryLab
A third format beside `.npy` and `.csv`, written by hand in `src/export.cpp` --
MF4 is HDF5 and would mean a library, MF3 is a handful of fixed blocks. The
layout is the asammdf 8.8.27 reader's own field list, byte-diffed against a
known-good 3.30 file it wrote; the shape is the one ScryLab's data-source
contract asks for: one channel group, record-interleaved, the master as the x
axis, and the unit in the channel's conversion block.

So in MDF the master is `abs_seconds`, named `time` -- the clock that is the
same in every display mode -- and the plotted axis goes out as an ordinary
channel, `time_relative`. `ExportTable::Column` gained the CONFIG's unit, which
only this writer spells out; the `.npy`/`.csv` bytes are unchanged, and
`write_export()` now picks the writer for both the dialog and the probe.
2026-10-14 15:56:27 +02:00
Martino Ferrari 761615c2fe fix: a restarted client is a new stream, so its history and its monotonic mark go
Going live from a file view left the display frozen for seconds: the per-second line showed
`ring=[2.5200..2.5200] n=567000 -> n=1131000 -> n=1689000` — the sample count climbing at the stream's rate
while the retained span was one value, the file view's last stamp. The status bar was green because
everything about the *connection* was fine.

It is the poisoning `invariants-data-path.md` already describes, entered through a different door. The
new client's stream clock sits *behind* the stamps the old stream left in `Acquisition::last_stamp_` (the
file view's samples are still in the store), so the advance test rejects every packet of a perfectly good
stream, four rejections abandon the declared time signal, and the arrival-clock fallback derives its epoch
from the surviving mark — every sample stamped flat at the old value. The guard did exactly what it was
built to do; the *stream* was the thing that had changed and nothing had told it.

So `Acquisition::start()` — which is the one place a client (re)starts — now says so:
`ScopeModel::reset_stream()` drops the previous stream's history (`store_->reset()`, which the data-path
invariants require to leave nothing behind) and moves `plan_generation_`, which is what the producer
already watches to drop the state tied to the old store; and `last_stamp_` is re-based to `-infinity` so
the monotonic guard re-bases on the new clock instead of pinning it. The declaration is kept: the first
packet of the new stream passes, and monotonicity holds from there.

One self-inflicted crash on the way, fixed before this: `reset_stream()` dereferenced `store_`, which is
**null until the first CONFIG has planned anything** — at start-up there is none — so the app died with
SIGSEGV and no output at all. Caught by running it plainly rather than trusting the change.

Verified with the events probe (a replay and a group, then *Live*):

    live restored (after replay): … pending=0
    trig engine: … history=0.4377/0.4000 s now_t=0.298 ring=[-0.1392..0.2985] n=569000
    trig engine: … history=0.8723/0.4000 s now_t=0.733 ring=[-0.1392..0.7331] n=1134000   (pending=1: it latched)
    trig engine: … history=1.2954/0.4000 s now_t=1.156 ring=[-0.1392..1.1562] n=1684000

`ring` moves within a round-trip, `history` fills, and the trigger latches the frame after the restore;
`make test-ui` 14 files, zero `Gtk-CRITICAL`, and the plain run is clean. Docs: the restart's own door into
the poisoning in `invariants-data-path.md`, and the `ring=` reading's entry in `probe-readings.md` now says
what it must do after *Go live*.
2026-10-14 15:38:00 +02:00
Martino Ferrari 62b5f14504 test: the per-second engine line shows the store's retained span, which pins a stalled clock
Diagnosing "going live from an event view does nothing for ~5 s, with the status bar green" needed a
distinction the engine's own fields cannot make. `n` counts every stored sample, so it climbs whenever data
arrives at all; what says *why* nothing moves is the store's stamp pair beside it. So the line gains
`ring=[oldest..newest] n=`:

    trig engine: … history=0.0000/0.4000 s … now_t=2.520 holdoff_until=2.520 ring=[2.5200..2.5200] n=567000
    trig engine: …                                       ring=[2.5200..2.5200] n=1131000
    trig engine: …                                       ring=[2.5200..2.5200] n=1689000

Three seconds apart, after `<live>`: the sample count climbs at the stream's rate (the data *is* arriving and
being stored) while the retained span is a single value — the file view's last stamp. So the producer's clock
is **frozen** after the restart, not lagging, which is why `history` never fills, why an armed trigger never
latches, and why a live view with the trigger off does not advance either: one cause, two faces, and it is
not the trigger.

The reading is what tells that apart from a stalled *feed* (ring moving, `now_t` still), which is a different
bug with a different fix — and neither is visible from `now_t` alone. `n` climbing beside a still `ring` is
the clock; a moving `ring` beside a still `now_t` is the feed.

The fix itself is the next piece of work: the producer's clock has to advance across a client restart (or the
file view's retained history has to go when going live), and this pair is the assertion it must satisfy —
after `<live>`, `ring` must move within a stream round-trip. Verified: `make test-ui` 14 files; the fields
observed in the transition run above; no `Gtk-CRITICAL`.
2026-10-14 15:32:01 +02:00
Martino Ferrari 4e29b958a2 fix: going live restores immediately instead of after a few trigger toggles
The symptom was "view a recorded event, click `<live>`, then toggle the trigger a few times before it
starts streaming again". The mechanism was the restore's own gate: `restore_live_state()` waits for the
*stream's* channel list, because the trigger's stored channel is a **name** and a file view that renamed its
channels (`square (A)`) must be gone before the name means the right signal — and that wait was gated on
`generation()` having moved since *Go live* was pressed.

That gate could never be satisfied in the common case. A **single** event keeps the stream's own channel
names, and the restarted client's CONFIG is identical — which the model treats as *no change on purpose*
(`same_stream_signals()`: "the honest recovery is nothing") — so nothing bumped the generation and the
restore sat there. Toggling the trigger *is* a structural change and moved it, which is exactly the
workaround the report describes.

The gate is now the question it was actually asking: is the snapshot's channel name resolvable against the
live channels (or did the snapshot name none)? For a single event that is true as soon as the client has a
channel list at all, so going live is immediate; for a group the names are renamed, so it waits for the
stream's own CONFIG exactly as before — which is what the gate was for. `live_restore_generation` is still
captured and printed (`gen=a/b`) as information, not as the gate.

Verified against the documented reading, both ways into a file view, from a session with a window and a
trigger so the paired fields have something to disagree with:

    [events] live restored (after replay):  window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=6/3
    [events] live restored (after a group): window=0.500000 (want 0.500000) trigger=1 (want 1) channel='square' (want 'square') level=0 (want 0) enabled=1 armed=1 pinned=0 held=0 mode=2 pending=0 gen=16/12

Every want satisfied, `pending=0` in both, `make test-ui` 14 files, zero `Gtk-CRITICAL`. Docs: the
invariants' bullet rewritten around what the restore waits for and why the generation was the wrong
question, and `probe-readings.md` gaining the thing that cost me two runs to find — those four paired
fields are the probe's capture of the live view, so a bare first run reads the application's defaults and
is not a defect; reproduce the documented line from a session that has a window and a trigger.
2026-10-14 15:10:49 +02:00
Martino Ferrari 8b2dae7655 fix: the overlaid sidebar steps aside for a drag, and the compact view follows the trigger
Two reports, both real, and neither was about the lock itself.

**A drag out of an overlaid sidebar had nowhere to land.** In a window too narrow to show both columns,
`AdwOverlaySplitView` puts the sidebar *on* the plot — that is what `collapsed` means — so the pointer
stays over the list and never over a drop target: a signal could be dragged but not dropped, which reads
as "dragging does not work". `row_drag_sidebar()` now hides the sidebar on `drag-begin` and restores it on
`drag-end`, and only when collapsed: side by side the plot is already visible, and hiding the list the drag
started in would be startling. The reading begins a drag through the *source* with the state forced
collapsed, the way the sidebar probe forces it:

    pane drag: sidebar hidden for the drag=1 restored after it=1

**The compact view did not follow the trigger switch.** The locked line's first branch reads
`tc.enabled` — with trigger mode off it says `stream (trigger off)` and nothing else — and
`on_trigger_toggled()` never re-synced it, so the line kept saying whichever it had been. One call to
`sync_trigger_summary()` beside the switch sync. The dump's two `panes:` lines were already the test and
now pass it: with a session whose trigger is off, the line as found reads `stream (trigger off)` and the
live one reads the settings, because the exit probe turns the trigger on in between. Both reading `stream`
was the defect.

Verified: the two lines above; `panes: the same line as the probe found it: 'mode: stream (trigger off)'`
against the live `'signal: square | condition: window, rising | level: 0.3 .. 0.6 | mode: normal'`;
`make test-ui` 14 files; zero `Gtk-CRITICAL`. Docs: the `pane drag:` reading and the invariants bullet for
the overlay, and the `panes:` pair's second job — the refresh test — written down so the next reader knows
what both lines reading `stream` means.
2026-10-14 15:04:10 +02:00
Martino Ferrari a43d3830f0 test: the locked signal list keeps its drag, and the reading says so
Asked for: dragging a signal onto a plot must still work in the compact (view) list. It does — the drag
source is on the *row* and was never conditional on the lock — but nothing said so, and "the lock rebuilds
this list and drops three of its controls" is exactly the change that could take the drag with it. Now it
is a reading: **`drag_sources` must be `rows - 1`**, one per signal row, the `MATH` divider not being
draggable.

    signals panel: … rows=3 signals=2 math=0 divider=1 drag_sources=2      (locked)
    signals panel: … rows=5 signals=4 math=0 divider=1 drag_sources=4      (configuration mode)

Measured the direct way first, with a throwaway walk of the list's rows and
`gtk_widget_observe_controllers()`: `locked=1 rows=3 rows_with_a_drag_source=2`, the third row being the
divider. That is where the number in the reading comes from, and the walk is what found the shape of the
answer rather than assuming it.

One consequence worth recording, because it is an improvement rather than a cost: a locked row can be
dragged from *anywhere* on it, the colour chip included. In configuration mode the chip carries a click
gesture that claims the event sequence precisely so a drag cannot start there (it opens the colour
chooser); the locked list does not build that gesture, so the chip is inert and the row's drag source
sees the press.

What the lock does *not* change is which signals are listed: only the ones that are on. A signal nobody
acquires draws nothing on a plot, so a row for it would be a drag that ends in an empty slot.

Verified: both lines above, from sessions differing only in whether one exists; `make test-ui` 14 files;
zero `Gtk-CRITICAL`. Docs: the `drag_sources` field with what a `0` would mean, the invariants' lock
bullet, and the README's note.
2026-10-14 14:57:29 +02:00
Martino Ferrari d8705a369f ui: Arm is disabled with trigger mode off, and the redundant Go-live button is gone
**`Arm` is now disabled while trigger mode is off**, and labelled `Arm` again: it is the one control in
that pane that is *runtime* rather than setting, so with no engine running there is nothing to arm.
It is also the only thing in the body that stays disabled — the settings themselves stay editable, which
is the point of the previous commit. `arm sensitive with the trigger off=0` is the reading.

**`Go live (trigger off)` is deleted.** Its handler was `trigger_toggle → FALSE`, i.e. exactly the
toolbar's trigger switch and the pane's own switch, which is what it was for: "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." With that switch now in the pane's own heading, the button was the same control
drawn a third time. The status line that told users about it says "or switch the trigger off to stream"
instead. The events pane's own `events_live` button is a different button on a different path — the file
view restore — and is untouched.

**And one silent regression this work uncovered.** The `trigger panel:` frame reading had been walking the
*wrapper* box the app puts around the `.ui`'s panel (heading row + locked summary + panel) ever since the
heading moved out of the file in the lock commit, so it reported `boxes=2 … search_inside=0/4` against a
panel that was entirely correct. It reads `trigger_body` — the file's own panel — again, and now says so in
its comment, because a reading that can silently start describing the wrong widget is the failure it exists
to catch.

Verified: `trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4` (the
documented shape, restored); `arm sensitive with the trigger off=0`; `trigger panel sensitive …=1`;
`panes:`/`panel switches:`/`pane edits:` unchanged; `make test-ui` 14 files; zero `Gtk-CRITICAL`.
2026-10-14 14:50:04 +02:00
Martino Ferrari 796dda4ad5 fix: the trigger stays editable while it is off, and the locked pane says "stream"
Two things the lock got wrong, and they are the same mistake seen twice: trigger-off was treated as "the
pane is disabled".

**The configuration was unreachable exactly when it was wanted.** `on_trigger_toggled()` set the trigger
*body* insensitive with the mode — a rule carried over from the five-revealer sidebar, where a closed pane
had nothing to edit. 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. The body is now never disabled; only the
*cursors'* readout still is, which is the pane whose content has nothing to show with the instruments off.

**The locked pane described a trigger that was not running.** With trigger mode off the summary is one
line — `mode: stream (trigger off)` — because the four lines it used to show (signal, condition, level,
mode) describe a trigger that is not there. With the trigger on it is those four plus whatever is not at
its default (`noise band`, `HF reject`, `pre-trigger`, `hold-off`).

The reading changes with it: `trigger panel sensitive …=1` is now a **regression guard** rather than a
state — the trigger's body is always live, so a `0` there means the settings have been locked out again.

Verified in both states, from sessions whose only difference is `trigger_enabled`:

    panes: trigger=locked signals=locked body_visible=0 summary='mode: stream (trigger off)'
    panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, rising | level: 0.3 .. 0.6 | mode: single | pre-trigger: 20 %'
    trigger panel sensitive before the probe toggled it: 1

The locked trigger pane is 72 px with the trigger off and 192 px with it on. Docs: the invariants' rule
for which body is disabled and why the trigger's is not, the `summary`'s two forms with what each means,
the README's deviation note, and the second `panes:` line renamed to say what it is — the line as the
probe found it, because only a run whose *session* has the trigger off shows the stream form.
2026-10-14 14:46:15 +02:00
Martino Ferrari 4f4851f4e3 fix: the [edit] buttons' icons, and the locked pane says four lines not one
Two things the lock shipped wrong.

**The "done" icon did not exist.** `emblem-ok-symbolic` is a breeze icon; Adwaita — which is what this
app runs under — does not have it, so the button that leaves configuration mode drew a missing-image
placeholder, i.e. nothing. Both names are now Adwaita's own: `document-edit-symbolic` (the pencil: edit)
and `object-select-symbolic` (the check: done). This is exactly the silent failure `toolbar icons:`
exists for, so the two `[edit]` buttons get the same treatment: `pane edits: locked=…(ok) config=…(ok)`
is read by clicking into configuration mode and back, so one line carries both names and
`gtk_icon_theme_has_icon()`'s verdict for each. The invariants now carry the rule, including the trap
that a name found under `breeze` is not an answer.

**The locked pane was too compact.** One line could not hold what a user opens the pane for, so it is now
a small definition list: `signal`, `condition`, `level`, `mode` on four lines, and after that 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. The channel is resolved from the acquired channels
and the condition quotes the figures its own shape reads (a width and its comparison, two levels, or a
timeout), never one it ignores. The pane is ~192 px tall locked against 580 in configuration mode.

One self-inflicted critical on the way: the probe read the summary with `gtk_label_get_text()`, and the
container is a grid now — `Gtk-CRITICAL: assertion 'GTK_IS_LABEL (self)' failed`. The text comes from the
sync that writes it instead.

Verified: `pane edits: locked=document-edit-symbolic(ok) config=object-select-symbolic(ok)`;
`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'`;
`make test-ui` 14 files; zero `Gtk-CRITICAL` and no missing icon.
2026-10-14 14:37:41 +02:00
Martino Ferrari 121abbefae feat: locked panes for a returning user, and the trigger is persisted
Two panes, two modes. **Locked** 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 (the Adwaita `emblem-system-symbolic`,
swapping to `emblem-ok-symbolic` in configuration mode) that flips its pane. A **first run** — no session
file for that source — never reaches `apply_layout()` and comes up in configuration mode, because
configuring the host is what a first run is for. The mode is a mode and not a setting, so it is
deliberately not persisted.

The locked signal list drops the rows of every signal that is off, which is the whole of "show active
signals": the list already filters on nothing, so the lock is one `continue`. With the rows go the acquire
tick, the overflow menu (Information, Calibrate…, Delete, rename) and the `MATH` divider's `+` — and the
colour chip stops being clickable, because choosing a colour is configuration too. Dragging a signal onto
a plot stays: that is which view shows what, not what the scope is set up to do.

**The trigger is persisted, settings and state both** — the settings *even while it is off*, which is the
point: a trigger configured once and switched off is still the trigger you come back to. `LayoutState`
gains fifteen flat `trigger_*` keys; the channel is stored by *name* (every rebuild renumbers the
channels, the model's own rule) and the two enumerations as **the words the panel's dropdowns show**,
because an index ties the file to a declaration order — the lesson `math_op_at()` already carries. The
words are shared with the panel now (`trigger_type_text()` and friends build the dropdown arrays), so the
two cannot drift.

Two ordering traps, both found by running it:

- **The channel name resolves twice over.** `rebuild_trigger_signals()` picks up
  `App::pending_trigger_channel` when the stream arrives *after* the load, and `apply_trigger_layout()`
  resolves it itself when the channels are already there — without the second path the name stayed
  pending past the one rebuild that would have used it, and the trigger silently sat on channel 0
  (`summary='pulse width · sine …'` where the session said `square`).
- **The trigger's state is applied by pressing the control, not by setting a flag**: the toggle is set
  *outside* the `updating_panels` window `apply_layout()` uses, because a toggle set inside it emits
  `toggled` into a handler that returns at once and nothing would happen — no arming, no events button,
  no sensitivity.

Verified, in both modes and both directions:

    panes: trigger=locked signals=locked body_visible=0 summary='window · square · 0.3…0.6 · 20 % pre'
    panes: trigger=config signals=config body_visible=1 summary=''
    signals panel: labels=2 list=1 rows=7 signals=2 math=4 divider=1

The locked trigger pane is 42 px tall against 580 unlocked. A hand-written session round-trips: every
value it named came back in the panel's own controls, `trigger=1/1` (the state arrived and the pane's
switch followed it), and the file the app wrote back carried all fifteen keys with the untouched ones at
their defaults. `[side-tab-probe] … advanced=4` on its documented recipe; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon.

Docs: the `panes:` reading with what each field means, the corrected `labels=` note (the panel's frame is
counted above the list now, because the heading sits in a heading row), the invariants for both the
persistence and the lock, the README's deviation note, and the removal of its claim that the trigger has
never been persisted — it is now, and the locked view is built from exactly that.
2026-10-14 14:31:46 +02:00
82 changed files with 7458 additions and 1396 deletions

No files matched your search

+2 -2
View File
@@ -38,7 +38,7 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
| File | What it covers | | File | What it covers |
| --- | --- | | --- | --- |
| [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. | | [build-and-run.md](docs/claude/build-and-run.md) | Configuring and building, the desktop-entry target, the command-line options, and the two vendored dependencies (`deps/plotter`, `deps/udpsclient`) including what was changed upstream and why. |
| [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly four build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. | | [verification.md](docs/claude/verification.md) | How to run the headless self-check, the **two baselines every run is read against** (exactly three build warnings; no `Gtk-CRITICAL`), and the environment variable that schedules each probe. |
| [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. | | [probe-readings.md](docs/claude/probe-readings.md) | The per-line reference for the self-check's output: what every probe and dump line means, the value it must read, and the defect a wrong reading names. |
### How the application is put together ### How the application is put together
@@ -60,4 +60,4 @@ The detail lives in `docs/claude/`. Read the files that touch the area you are e
| [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted with hysteresis. | | [invariants-measurements.md](docs/claude/invariants-measurements.md) | Why the pane is refreshed on a cadence, why neither pass may read its window raw, and how a crossing is counted with hysteresis. |
| [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. | | [invariants-math-channels.md](docs/claude/invariants-math-channels.md) | A math channel *is* a channel; the engine runs on the producer thread, so a regression there costs packets and not frames; the node editor's own rules. |
| [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. | | [invariants-session-and-export.md](docs/claude/invariants-session-and-export.md) | What a session file persists and in what order, when a save is refused, and when an export is collected. |
| [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the six bundled icons and their authoring rules, and the help pages. | | [invariants-gtk-and-chrome.md](docs/claude/invariants-gtk-and-chrome.md) | The GTK signal-signature and dialog-lifetime traps this app has already fallen into, the status bar's two tiers, the six bundled icons and their authoring rules, and how the Mallard manual is opened. |
+101 -18
View File
@@ -11,6 +11,8 @@ if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
endif() endif()
find_package(Threads REQUIRED) find_package(Threads REQUIRED)
# `CMAKE_INSTALL_DATADIR` (the `share` under the prefix) for the help set's install path.
include(GNUInstallDirs)
# --- libadwaita: the shortcut overview --------------------------------------- # --- libadwaita: the shortcut overview ---------------------------------------
# GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and libadwaita's # GTK4's own `GtkShortcutsWindow` is deprecated as of 4.18 and libadwaita's
@@ -61,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)
+266 -40
View File
@@ -75,7 +75,7 @@ Every size it prints is read from a value the frame tick captured while the wind
from the widget: the dump runs from the destroy handler, where the widget pointer is already freed from the widget: the dump runs from the destroy handler, where the widget pointer is already freed
memory. It used to ask two of them directly, which is where the long-standing "an exit-dump run memory. It used to ask two of them directly, which is where the long-standing "an exit-dump run
emits exactly two `Gtk-CRITICAL`s" baseline came from — and a critical was only the lucky reading. emits exactly two `Gtk-CRITICAL`s" baseline came from — and a critical was only the lucky reading.
`UDPSCOPE_PROBE_HELP_MS` builds and destroys three windows of its own before the dump, so by then `UDPSCOPE_PROBE_HELP_MS` builds and destroys the About and shortcut dialogs before the dump, so by then
the memory had been reused and the same two calls **crashed in the middle of the dump**, which the memory had been reused and the same two calls **crashed in the middle of the dump**, which
looks like a truncated log rather than like a segfault in a debug line. The dump now makes no looks like a truncated log rather than like a segfault in a debug line. The dump now makes no
`gtk_widget_*` call at all and a clean run carries no critical. `gtk_widget_*` call at all and a clean run carries no critical.
@@ -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
+44
View File
@@ -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()
+57
View File
@@ -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.
+12
View File
@@ -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
+272 -24
View File
@@ -1,6 +1,6 @@
# Invariants: GTK traps, the status bar, the icons and the help # Invariants: GTK traps, the status bar, the icons and the manual
The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and the help pages. The GTK signal-signature and dialog-lifetime traps this app has already fallen into, plus the status bar's two tiers, the bundled icons and how the Mallard manual is installed and opened.
Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it.
@@ -111,19 +111,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.
+78 -10
View File
@@ -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
+29 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+87
View File
@@ -0,0 +1,87 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="cursors">
<info>
<link type="guide" xref="index#analysis"/>
<link type="seealso" xref="measurements"/>
<desc>The shared vertical cursor pair and the per-row horizontal rulers, their four modes,
and how a reading is formatted.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Cursors</title>
<p>Cursors read values off the traces. There are two kinds: one <em>shared vertical
pair</em> that every row carries at the same time, and <em>horizontal rulers</em>, one pair
per row, that belong to that row alone.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Press the cursors button in the toolbar, or <key>c</key>.</p>
</item>
<item>
<p>The pane below the plot shows the readings and owns a <gui>Reset</gui> button.
Drag a marker to move it.</p>
</item>
<item>
<p>The mode dropdown offers four modes: <gui>vertical</gui> (the shared time pair),
<gui>horizontal</gui> (the per-row rulers), <gui>both</gui>, and <gui>mix</gui>.</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The vertical pair is <em>one</em> time for the whole figure, so moving it on
any row moves it on all of them. That is what makes <code>dt</code> meaningful across
rows.</p></item>
<item><p>A ruler belongs to its row and nothing shares it, so two rows' rulers are
independent.</p></item>
<item><p><gui>mix</gui> is the reading mode: one line per <em>bound</em> trace, with the
trace's own values at each marker and their difference. A slot with a name but no
channel behind it is not a trace and gets no line.</p></item>
<item><p>A value is the <em>nearest stored sample's</em>, never a number between two
samples. Zoom in past the sample spacing and the line between two samples is something
the plot drew, not data — so a reading off it would be the drawing. The hover crosshair
reads the same way.</p></item>
<item><p>Every reading carries a <em>metric prefix</em> so the digits sit in
<code>1..999</code>: <code>10.52 mV</code>, not <code>0.01 V</code>. Two decimals is all
an instrument landing on an <code>f32</code> sample can claim, so without the prefix a
small value would read <code>0.00</code> and the reading would be gone. A stream that
declares no unit gets the prefix anyway, written tight against the number
(<code>585.24m</code>) — the space separates the number from its unit, and a spaced
<code>m</code> with nothing after it would read as metres.</p></item>
<item><p>A marker past the newest sample reads <code>--</code>. With only one marker
over data the row reads <code>partial</code>: a difference between a number and nothing
is not a number.</p></item>
<item><p>In <gui>mix</gui> each marker also places a crossing annotation per trace,
which is what lets you see on the plot which sample a number came from. It is <em>on
that sample</em>, so when the marker falls between two it sits a little off its own
line: the line is where you put the marker, the dot is the datum being quoted. The hover
crosshair's mark snaps the same way, and is drawn larger than a trace's sample dots so
it does not read as one more sample.</p></item>
<item><p>The time row exists only in the modes that show the vertical pair.
<gui>horizontal</gui> has no time row, by design.</p></item>
<item><p>A cursor <em>stays where you put it</em> while a live view rolls under it,
which is what a bench scope does: its <code>T0</code> is an age, so a marker left at
<code>-1.5s</code> keeps reading the sample a second and a half old as the stream
advances. A measurement's marks do the opposite — they travel with the sample they
measured — because a cursor is a question you asked and a mark is an answer about one
particular sample.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>Three traces in <gui>mix</gui> mode with both markers over data: 3 lines and 6 crossing
marks, each line reading like <code>sine 0.31 V -0.84 V -1.15 V</code>. Move the second
marker past the newest sample and the third column reads <code>--</code>.</p>
</section>
</page>
+78
View File
@@ -0,0 +1,78 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="export">
<info>
<link type="guide" xref="index#output"/>
<link type="seealso" xref="sessions"/>
<desc>Writing the visible range of every plotted trace to NumPy, CSV or MDF 3.30, and what
each format carries.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Saving and export</title>
<p>Export writes the <em>visible range</em> of every plotted trace to a file: NumPy
<file>.npy</file>, <file>.csv</file>, or <gui>MDF 3.30</gui> (<file>.mf3</file> or
<file>.mdf</file>), chosen by the extension you type.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Pick <gui>Export visible range…</gui> in the overflow menu, or press
<keyseq><key>Ctrl</key><key>E</key></keyseq>.</p>
</item>
<item>
<p>A file chooser asks where; the format follows the path's extension.</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The table is collected at the moment you click, not when the chooser returns. A
live view keeps scrolling while a dialog is up, so a late collection would write a window
you never saw.</p></item>
<item><p>The rows are the <gui>stored samples</gui>, never the LOD buckets the plot draws
from. A row count far below the span times the stored rate means something cut the table
from the pyramid.</p></item>
<item><p>The stored rate is not always the wire rate. A window long enough to need
decimation is stored averaged at a lower rate, and the export carries <em>that</em> —
read the status bar's ⓘ popover for the figure before trusting a sample count.</p></item>
<item><p>Two time columns. <code>time</code> is the display timeline — negative ages
ending at 0 while streaming, offsets from the trigger edge while an event is held — so it
is the x you read a value at on screen. <code>abs_seconds</code> is the source's own
clock for the same sample, which is what lets two exports taken in different modes line
up. Both are strictly increasing and evenly spaced; duplicate or backwards stamps would
mean the file held two clocks.</p></item>
<item><p>An export taken <em>mid-capture</em> writes the partly filled window that is on
screen, which is shorter than the event span. Export outside a capture if you want the
whole event.</p></item>
<item><p><gui>MDF</gui> exists for handing a window to another tool, and its shape follows
the contract that reader uses: one channel group, every channel record-interleaved, the
master channel as the x axis and the unit in the channel metadata. So there the
<em>master is <code>abs_seconds</code></em>, named <code>time</code> — the clock that is
the same in every display mode — and the display axis goes out beside it as an ordinary
channel, <code>time_relative</code>. Both are seconds; each signal carries the source's
own unit. That is also what <gui>ScryLab</gui> reads: channel name → signal name, master
→ x axis, unit → the signal's unit.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>A 2 s window at 10 kS/s over 3 traces writes 20001 rows and 5 columns
(<code>time</code>, <code>abs_seconds</code>, three signals). The <file>.npy</file> is
<code>64·⌈(10+len(header)+1)/64⌉ + rows·(16 + 4·cols)</code> bytes. Read it back with
<code>pd.DataFrame(np.load(path))</code>. The <file>.mf3</file> is bigger — a block per
channel plus a comment — <code>272 + comment + rows·record + 28 + channels·274 + 30</code>
bytes, with <code>record = 16 + 4·cols</code> and <code>channels = cols + 2</code> for the
master and the relative axis. Read that one back with <code>asammdf.MDF(path)</code> or in
ScryLab.</p>
</section>
</page>
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

+67
View File
@@ -0,0 +1,67 @@
<page xmlns="http://projectmallard.org/1.0/"
type="guide" style="task"
id="index">
<info>
<desc>udpscope plots a live UDPStreamer source as one figure of up to eight stacked
plot rows sharing a single time axis, with a trigger, cursors, measurements, math
channels and an export. This manual covers connecting, the window and its panels,
navigating and pausing the plot, the trigger and its event cache, analysis, and
saving and exporting.</desc>
</info>
<title>udpscope</title>
<section id="main">
<title>Getting started</title>
<list>
<item><p><link xref="overview">Overview</link> — what udpscope is, how it reaches a
source, the window and its panels, and a first five minutes.</p></item>
<item><p><link xref="source">Choosing a source</link> — the command line, the
chooser, and opening an exported waveform instead of a live stream.</p></item>
<item><p><link xref="signals">Signals and calibration</link> — how a signal becomes
a channel, what starts acquisition, and how a scale and offset are applied.</p></item>
<item><p><link xref="layout">Rows, layout and drag &amp; drop</link> — up to eight
rows, four traces each, and the layout editor.</p></item>
</list>
</section>
<section id="viewing">
<title>Viewing</title>
<list>
<item><p><link xref="window">Time window</link> — the one control that changes the
window, and the decimation a long window causes.</p></item>
<item><p><link xref="navigate">Navigating the plot</link> — panning, zooming and
fitting with the mouse and the keyboard.</p></item>
<item><p><link xref="pause">Pause and play</link> — freezing the display without
stopping the stream, and how long you have been looking.</p></item>
<item><p><link xref="trigger">Trigger</link> — the four trigger shapes, arming,
hold-off, the event cache, and searching the buffer.</p></item>
</list>
</section>
<section id="analysis">
<title>Analysis</title>
<list>
<item><p><link xref="cursors">Cursors</link> — the shared vertical pair and the
per-row horizontal rulers.</p></item>
<item><p><link xref="measurements">Measurements</link> — a statistic of the visible
window, its on-plot marks, and its statistics line.</p></item>
<item><p><link xref="math">Math channels</link> — building a derived channel from a
graph of source nodes, operations and one output.</p></item>
</list>
</section>
<section id="output">
<title>Output and state</title>
<list>
<item><p><link xref="export">Saving and export</link> — the visible range to
<file>.npy</file>, <file>.csv</file> or <file>.mf3</file>.</p></item>
<item><p><link xref="sessions">Sessions and settings</link> — the per-source
configuration files and what they carry.</p></item>
<item><p><link xref="status">Status bar and diagnostics</link> — the glance line, the
ⓘ popover, and the activity badge.</p></item>
</list>
</section>
</page>
+71
View File
@@ -0,0 +1,71 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="layout">
<info>
<link type="guide" xref="index#main"/>
<link type="seealso" xref="signals"/>
<desc>How the figure's rows and traces are arranged, how to drop a signal onto a row, and
how the layout editor adds and removes rows.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Rows, layout and drag &amp; drop</title>
<p>The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share
one time axis and one pair of side margins, so their y labels line up, their data areas
start and end on the same two columns, and their windows cannot drift apart. The left
margin holds the y labels; the right one is empty, and is there so the newest tick's label
and the newest sample's ink have somewhere to land instead of sitting on the frame.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Drag a signal's chip out of the side panel (<key>s</key>) and drop it on a row to
add a trace.</p>
</item>
<item>
<p>Press <key>l</key>, or pick <gui>Layout editor</gui> from the overflow menu, to add
and remove rows: a <gui>×</gui> appears at the top left of each row and a <gui>+</gui>
on each boundary between rows, including above the first and below the last.</p>
</item>
<item>
<p>The editor's own button in the top-right corner leaves it.</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>A plot's own <gui>right-click menu</gui> (the same compact rows as the app's
menu) holds the y-axis scale, the two fits, and <gui>Remove signal</gui>, which swaps
the menu to that plot's traces plus <gui>All traces on this plot</gui> — with a
<gui>Back</gui> row to return. That is how a trace comes off a row without going through
the layout editor.</p></item>
<item><p>A drop <em>adds</em> a trace to the row's next free slot; it does not replace
one. With four traces already on a row the drop is refused.</p></item>
<item><p>Inserting a row moves the rows below it <em>with their contents</em>. Inserting
above row 1 puts what row 1 plotted onto row 2 and leaves the new row empty — the rows
are not renumbered under you.</p></item>
<item><p>A trace is remembered by the signal's <em>name</em>, not by its channel number,
because a new CONFIG or an enable renumbers the channels and an index would silently
re-point the trace.</p></item>
<item><p>Adding or removing a row rebuilds the figure, which is cheap, but it also
re-deals every trace. A frozen trigger event survives the rebuild: the pinned window is
restated rather than re-framed on the live edge.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>With three traces on row 1, pressing <key>l</key> shows 3 buttons (one <gui>×</gui>,
two <gui>+</gui>). Clicking the <gui>+</gui> above row 1 gives 2 rows and 5 buttons, with
the three traces now on row 2 — the layout probe reports exactly this as
<code>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</code>.</p>
</section>
</page>
+110
View File
@@ -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>
+122
View File
@@ -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>
+104
View File
@@ -0,0 +1,104 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="navigate">
<info>
<link type="guide" xref="index#viewing"/>
<link type="seealso" xref="window"/>
<desc>Panning, zooming and fitting the plot with the mouse and the keyboard, and what a
pinned view refuses to do.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Navigating the plot</title>
<p>The plot pans and zooms with the mouse and with the keyboard. Every gesture acts on the
row the pointer is over, and because the rows share one time axis, an x change moves all of
them.</p>
<section id="reach">
<title>How to reach it</title>
<terms>
<item>
<title>Wheel</title>
<p><gui>Wheel</gui> zooms both axes · <gui>Shift</gui>+<gui>Wheel</gui> zooms x only ·
<gui>Ctrl</gui>+<gui>Wheel</gui> zooms y only.</p>
</item>
<item>
<title>Drag</title>
<p><gui>Drag</gui> pans · <gui>Shift</gui>+<gui>Drag</gui> is a box zoom on both axes ·
<gui>Ctrl</gui>+<gui>Drag</gui> on x only · <gui>Alt</gui>+<gui>Drag</gui> on values
only. The area being chosen is framed while you drag it, so you can see what the
release will apply.</p>
</item>
<item>
<title>Keyboard</title>
<p><key>+</key> and <key>-</key> zoom both axes; <key>Ctrl</key> with them restricts
to x, <key>Alt</key> to y. <gui>Arrow keys</gui> pan, <key>f</key> fits,
<key>Escape</key> cancels a gesture in progress.</p>
</item>
</terms>
<p>The full table is in the shortcut overview (<keyseq><key>Ctrl</key><key>/</key></keyseq>).</p>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The keyboard zoom's modifiers are <em>not</em> the wheel's, and that is
deliberate: on common layouts <key>+</key> <em>is</em> <keyseq><key>Shift</key><key>=</key></keyseq>,
so a bare zoom-in already arrives with Shift set and Shift could not mean anything.
Ctrl/Alt mirror the box-zoom drags instead.</p></item>
<item><p>A <em>pinned</em> view is navigable within its own data but cannot leave it.
While an event is held, a capture is filling, or the view is paused, a zoom <em>in</em>
works normally — "not outside the event" is not "not readable" — but a pan or a zoom out
is clamped to the data.</p></item>
<item><p>Nothing you do on the plot changes the <em>time window</em> — only the header
control does. A zoom or a pan moves the view inside the window; <key>f</key> (and
<gui>Fit X axis</gui>) is the <em>undo</em> of a zoom, so a 2 s window zoomed into reads
2 s again. The window is more than the axis: it is also the trigger's event span and how
the samples are stored, so a wheel notch that redefined it would re-plan the buffer and
drop your history.</p></item>
<item><p>The axis does not scroll — the <em>data</em> does. A live axis is a fixed
<code>[-window .. 0]</code> with the newest sample at 0, so the labels stand still and
the trace rolls under them. The cursors stand still with the labels (they are positions
on screen, so a cursor you placed stays where you put it, over newer data each frame),
while a measurement's marks travel with the sample they measured and leave the view when
it does.</p></item>
<item><p>A live view is clamped to the window you asked for, not to what the ring
happens to hold. That is why a buffer that has only just started filling leaves its
left-hand side empty instead of squeezing the window down to the first few
samples.</p></item>
<item><p>Zooming hard into a held capture is exact. The renderer finds the visible
samples by binary search over the real timestamps, never over a nominal sample grid, so
a capture whose clock wandered does not drift away from its axis as you zoom.</p></item>
<item><p>A trace holding far more samples than the plot has pixels is drawn <em>one
pixel column at a time</em>: each column is a single vertical line from the lowest to
the highest value that falls in it. Drawn as a polyline instead, such a window puts
three or four near-vertical lines in every column at slightly different sub-pixel
positions, and how they overlap beats against the pixel grid — which reads as bands of
light and dark that <em>crawl</em> as you pan. One line per column removes the beat, and
it is also much faster: a 1 MS/s view costs a couple of thousand line segments a frame
instead of a couple of hundred thousand.</p></item>
<item><p>Those columns are <em>graded by brightness</em>, the way a digital-phosphor
scope reads: bright where the signal spent its time, faint where it only passed through.
A column's highest and lowest value are the same whether the samples in it are spread
evenly or piled at its two ends, so the grading is what tells the two apart — the flat
parts of a square wave come out solid and the edges between them faint. It is read from
a finer division of each column rather than from the line that is drawn, and it costs
about a millisecond a frame.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>Pause a live view, then press <keyseq><key>Ctrl</key><key>+</key></keyseq>: x narrows
and y does not move. Press <keyseq><key>Alt</key><key>+</key></keyseq>: y narrows and x
does not. Eight doublings into a held 2 s capture, the trace still reaches both margins —
the zoom probe reports <code>gap_px</code> and <code>end_px</code> within a pixel or two
at every step.</p>
</section>
</page>
+192
View File
@@ -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>
+61
View File
@@ -0,0 +1,61 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="pause">
<info>
<link type="guide" xref="index#viewing"/>
<link type="seealso" xref="trigger"/>
<desc>Pausing freezes the display without stopping the stream, and the status bar says how
long you have been paused.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Pause and play</title>
<p>Pause freezes the <em>display</em>, not the acquisition. The producer keeps filling the
ring at full rate; the view binds an immutable snapshot of the window that was on screen,
and the axis is pinned to it.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Press the pause button in the toolbar, or <key>Space</key>.</p>
</item>
<item>
<p>Press it again to resume, which rebinds the live ring and puts the window back on
the newest data.</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>Nothing is lost while paused. Because the producer never stops, resuming shows
current data with no hole in the history behind it.</p></item>
<item><p>The axis is pinned to the <em>snapshot's own stamps</em>, not to the window that
was requested. A window reaching back past the oldest retained sample yields a shorter
snapshot, and the pinned axis is that shorter span — so you see a narrower view rather
than an empty margin.</p></item>
<item><p>A paused view is pinned exactly as a held trigger event is: zoom in freely, but
a pan or a zoom out is clamped to the snapshot's data.</p></item>
<item><p>Pausing is the easiest way to measure a layout or read a cursor precisely,
because nothing under the pointer moves between frames.</p></item>
<item><p>The status bar says how long you have been paused — wall time, since the stream
itself never stopped. It is there so a frozen picture cannot be mistaken for a live one
that went quiet.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads
<code>[-1.40 .. 0.00]</code> rather than <code>[-2.00 .. 0.00]</code> with 0.6 s of blank.
The pause probe reports <code>ring_produced</code> still growing across the pause and
<code>event_size</code> non-zero exactly while paused.</p>
</section>
</page>
+93
View File
@@ -0,0 +1,93 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="sessions">
<info>
<link type="guide" xref="index#output"/>
<link type="seealso" xref="source"/>
<desc>Per-source configuration files: where they live, what they persist, and how the
chooser and the Configurations dialog use them.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Sessions and settings</title>
<p>udpscope stores named sessions — <em>configurations</em> — per source, so reopening a
source gives you the scope you closed, and one source can hold several arrangements for
different experiments. There is no global preferences file: the session <em>is</em> the
settings.</p>
<section id="reach">
<title>How to reach it</title>
<p>Nothing to open — the file is written as you work. Its path is in the About dialog, and
the directory is <file>$XDG_CONFIG_HOME/udpscope</file>, or <file>~/.config/udpscope</file>
when that is unset. Each source has a directory <file>&lt;host&gt;_&lt;port&gt;/</file>
holding one <file>&lt;name&gt;.toml</file> per configuration and an <file>active</file>
file naming the one it is on.</p>
</section>
<section id="configs">
<title>Configurations</title>
<p><gui>Configurations…</gui> in the menu lists the running source's configurations: click
a row to open it, <gui>×</gui> to delete it, or type a name and a note and press
<gui>Save this session</gui> to keep the current arrangement under a new one. Opening a
configuration saves the current session first, so switching never loses what it switches
from.</p>
<p>The startup chooser (start with no <cmd>--server</cmd>) lists one row per configuration,
with its note and its settings, so two arrangements of one source are told apart before
they open.</p>
<p>A configuration also carries the settings that can only be decided when the buffers are
allocated: the <gui>buffer size</gui> (<cmd>--buffer-mb</cmd>), relative time, and the
packet capture's ring size. Opening a configuration from the chooser applies all three;
switching inside a running app applies the rest and says in the status line that these take
effect on the next start.</p>
<p>The chooser's form sets the first two for a new host, and its pencil button edits them
on an existing configuration — along with the name and the note. Renaming a configuration,
or editing the address it points at, moves it: the old file is removed rather than left
behind.</p>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>What a configuration persists: the time window, the rows and each row's slot
signals, the calibration, the trace colours, which signals were enabled, the math
channels with their graphs, the measurements pane — and the startup settings: buffer
size, relative time and the packet capture's ring size.</p></item>
<item><p>Signal identity is the <em>name</em> throughout, because a new CONFIG renumbers
the channels.</p></item>
<item><p>A file written by a run against a source whose streamer was down would name no
signals at all, so such a run does <em>not</em> write: a configured session is never
overwritten by a run that configured nothing. (The chooser hides such a file for the same
reason.)</p></item>
<item><p>The multicast group and the interface live <em>inside</em> the file, because its
name cannot carry them. A file from an older build has neither key and loads as unicast
on the default route.</p></item>
<item><p>The chooser lists only configurations that name at least one signal, which is
what keeps it the sessions you configured rather than every port you have ever tested
against.</p></item>
<item><p>Deleting a configuration is a supported way to start over; the chooser's
<gui>×</gui> and the dialog's do exactly that.</p></item>
<item><p>A session from before configurations existed is imported once as the
configuration <code>default</code>, and the old file is kept beside the directory as
<file>&lt;host&gt;_&lt;port&gt;.toml.imported</file>.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>Connect to <code>127.0.0.1:8000</code>, drop three signals on row 1, set a 2 s window,
and save the session as <code>bench</code> with a note.
<file>~/.config/udpscope/127.0.0.1_8000/bench.toml</file> now holds
<code>window_s=2 comment="…"</code> with
<code>signals = ["sine", "ramp", "noise", ""]</code>, and the next run reports
<code>layout loaded from …/127.0.0.1_8000/bench.toml</code> with those signals already
acquired.</p>
</section>
</page>
+70
View File
@@ -0,0 +1,70 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="signals">
<info>
<link type="guide" xref="index#main"/>
<link type="seealso" xref="layout"/>
<desc>How the source's CONFIG becomes channels, what enabling a signal starts, and how a
signal's scale and offset are calibrated.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Signals and calibration</title>
<p>The source's CONFIG message describes its signals: a name, a unit and a type. udpscope
turns each enabled signal into a <em>channel</em> — a ring of values beside the shared time
ring — and a channel is what a trace plots. Calibration is a scale and an offset applied at
ingest.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Open the side panel (<key>s</key>), which lists every signal with a switch.</p>
</item>
<item>
<p>The <gui>Signal list</gui> item in the overflow menu opens the editor where the
unit and the calibration live.</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>Signals start disabled, and enabling one is what starts acquiring it. A trigger
on a signal nobody acquires can never fire, so arming a trigger enables its signal for
you.</p></item>
<item><p><gui>Calibrate</gui> in a signal's row menu opens the gain and offset it is
<em>currently</em> drawn through, so a trace already scaled by a gain of 2 shows 2 —
pressing <gui>Apply</gui> confirms it rather than resetting it. A signal nobody
calibrated shows the identity, 1 and 0.</p></item>
<item><p>Calibration is applied <em>at ingest</em>, so changing it re-plans the buffer
and drops the history. So does enabling or disabling a signal, and so does a new
CONFIG. An empty plot for a second after one of those is the ring refilling, not a
fault.</p></item>
<item><p>The stream's first signal is usually its <em>timestamp</em>, and that one is
never acquired. It cannot be plotted, measured or used as a math source, and udpscope
refuses to put it in a row rather than binding a trace that would read nothing for
ever.</p></item>
<item><p>A signal's colour follows the <em>signal</em>, not the slot, so the same signal
on two rows is one colour and the side panel's chip identifies it unambiguously. A
signal on no row gets a grey chip rather than none, so the panel's rows keep their
height.</p></item>
<item><p><em>Click a coloured chip to change that signal's colour.</em> A grey chip
names no trace, so it is not clickable. The colour is saved with the session;
<gui>Default colour</gui> in the signal's ⋯ menu gives the palette entry back.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>A source declaring <code>time</code>, <code>sine</code>, <code>ramp</code> and
<code>noise</code> yields three plottable signals. Enable all three and the exit dump
reads <code>3 signals, 3 channels</code>; <code>time</code> is not among them.</p>
</section>
</page>
+75
View File
@@ -0,0 +1,75 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="source">
<info>
<link type="guide" xref="index#main"/>
<link type="seealso" xref="sessions"/>
<desc>How udpscope is pointed at a source: the command line, the startup chooser, and
opening an exported waveform instead of a live stream.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Choosing a source</title>
<p>A source is a host and a port, optionally reached through a multicast group on a named
interface. Pass it on the command line, or let udpscope ask.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>Run with <cmd>--server</cmd> and the app connects straight away.</p>
</item>
<item>
<p>Run <em>without</em> <cmd>--server</cmd> and a chooser appears first: one row per
stored <em>configuration</em> (<code>host:port · name</code>, its note, and the
settings it carries), newest first, plus a <gui>New host…</gui> form that takes the
address <em>and</em> the configuration it belongs to — its name and note, the
<gui>buffer size</gui>, and whether time is relative or absolute. A row's pencil
button opens the same form on that configuration, pre-filled, and changes to its name
or address move it (the old file is deleted).</p>
</item>
<item>
<p>The second page, <gui>Waveforms</gui>, opens an <em>exported</em> run instead of a
live source: the files opened before (newest first; one that has since been deleted is
marked <em>missing</em>) and an <gui>Open…</gui> button for a new one. The same file
can be opened straight from the command line with <cmd>--open FILE</cmd>. A waveform
is a source that never arrives — no socket, no acquisition, no starvation notice — and
every instrument works on it: cursors, measurements, math channels and another export.
Only <file>.npy</file> files are read.</p>
</item>
</steps>
<p>The chooser runs before any buffer is allocated and before any socket is opened, so
declining it costs nothing.</p>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The list is the sessions that <em>named a signal</em>. A session whose streamer
was down stores empty slot names and is filtered out, which is what keeps the list the
sources you actually configured rather than every port you have ever typed.</p></item>
<item><p>A stored row reconnects the way it was reached. The multicast group and the
interface are written inside the session file, because the file's name
(<file>host_port.toml</file>) cannot carry them.</p></item>
<item><p><cmd>--multicast</cmd> still needs <cmd>--server</cmd>: the CONFIG message is
fetched from the server, not from the group.</p></item>
<item><p>The <gui>×</gui> beside a row deletes that session file. Nothing else is
removed.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p><cmd>udpscope</cmd> with two stored sessions lists
<code>127.0.0.1:8000 plots=1 signals=3</code> and
<code>10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9</code>.
Activating the first reports <code>[sources] picked 127.0.0.1:8000</code> and continues
into the scope with that session's layout already loaded.</p>
</section>
</page>
+72
View File
@@ -0,0 +1,72 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="status">
<info>
<link type="guide" xref="index#output"/>
<desc>The status bar's two tiers, the activity badge, the fixed state words, and the ⓘ
detail popover.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Status bar and diagnostics</title>
<p>The status bar is two tiers. The bar itself is one glance line — the state, the rate and
the window — and everything else is behind the ⓘ button at its right-hand end.</p>
<section id="reach">
<title>How to reach it</title>
<p>The bar is always visible under the plot. Click <gui>ⓘ</gui> for the detail popover.</p>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>The badge is one glyph in four colours: green <em>alternating</em> lit and dim
means data is arriving, and a steady colour means it is not. A badge that never blinks is
not a working badge. Of the two steady colours, <em>grey</em> is no stream at all or a
pipeline you stopped (the tooltip says which), and <em>yellow</em> is a source that
answered but has sent no configuration.</p></item>
<item><p>The state field is one word from a fixed set: <code>connecting</code>,
<code>live</code>, <code>paused</code>, <code>armed</code>, <code>capturing</code>,
<code>event held</code>, <code>disarmed</code>. Two states add one field each: in trigger
mode, how long ago the last event was; while paused, how long you have been paused.</p></item>
<item><p><code>connecting</code> means there is no <em>signal list</em> yet — either no
session or a session with no configuration behind it. Every other word claims a signal
list, so until one arrives there is nothing for them to describe: nothing can be enabled
and no data is owed.</p></item>
<item><p>The paused figure is <em>wall</em> time, not stream time — the producer never
stops while paused, so it answers "how long have I been looking at this picture".</p></item>
<item><p>Nothing on the bar may change width with the stream, because the bar sits
directly above the plot and a wider label would relayout it. That is why the rate
collapses to one figure or a range rather than listing per channel, and why an elapsed
time steps to a coarser unit instead of growing digits.</p></item>
<item><p><code>gaps</code> counts packets the receiver knows it missed. It is the number
that matters: a non-zero count means samples are absent from the buffer, not merely
late.</p></item>
<item><p><code>reconnects</code> counts sessions re-established with the source. Over
unicast a climbing count usually means a second client is taking the source's single
session.</p></item>
<item><p>The starvation notice — <em>connected, but no data is arriving</em> — is that
same situation seen from the other side: the signal list is here, so something took the
stream away. A source that has sent <em>no</em> configuration gets a different notice,
<em>trying to connect</em>, because nothing was taken away — pointing the app at a port
where nothing answers is that case, and the usual cause is a streamer that is not running
or is serving another port.</p></item>
<item><p><code>stored</code> versus <code>wire</code> in the popover is the decimation.
They differ exactly when the window is too long to store at full rate.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>The bar reads <code>● live · 10 kS/s · 2 s window</code>. Behind ⓘ:
<code>Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3
channels</code>, <code>Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire
10 kS/s)</code>, <code>Plot 1 3 traces · 195000 samples</code>,
<code>Reconnects 0</code>.</p>
</section>
</page>
+198
View File
@@ -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/&lt;source&gt;/</file>) as a <file>.npy</file> of the
event, a <file>.txt</file> of what it was, and a <file>.bmp</file> picture drawn from the
samples. The <gui>Events</gui> pane on the left — opened from the toolbar's
event-history button, which appears while recording is enabled, and collapsed until you
ask for it — lists the last 32 newest first (time, duration, signals, rate), each row with
its own picture beside the numbers.</p>
<p>Clicking a row <em>replays</em> it: the stored event is loaded exactly like an exported
file, <em>without changing the layout</em> — the rows, the colours, the window, the
cursors and the measurements stay where they are and read the stored data instead. A
replay leaves trigger mode, because a file has no edge to wait for, and the pane stays
open. Drag the pane's edge to resize it. <gui>Live</gui> goes back to the stream; the
status bar names the file until you do.</p>
<p>Select two or more events in the list and the two buttons under the header act on them:
<gui>Compare</gui> puts two events in two plots side by side, keeping the layout you have
— one plot per event, the axes synchronised, so zooming a row in one zooms the same row in
the other — and <gui>Accumulate</gui> overlays up to four of them in the same traces,
translucent, so where they agree the trace is brighter. Your own traces stay where they
are in both. They need events with the same signals and the same length. A single click
selects, a <em>double</em> click replays.</p>
<p><gui>Record events</gui>, in the trigger panel with the trigger's other options, is
what decides whether anything is kept. It is <em>off by default</em> — nothing is written
to disk until you ask — and with it off the history button goes with it.</p>
</section>
<section id="search">
<title>Search the buffer</title>
<p><gui>Search the buffer</gui>, 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>
+66
View File
@@ -0,0 +1,66 @@
<page xmlns="http://projectmallard.org/1.0/"
type="topic" style="task"
id="window">
<info>
<link type="guide" xref="index#viewing"/>
<link type="seealso" xref="navigate"/>
<desc>The time window control: how to set it, and why a long window is decimated rather
than refused.</desc>
<credit type="author copyright">
<name>Martino Ferrari</name>
</credit>
</info>
<title>Time window</title>
<p>The window is how much time the plot shows. Every row shares one window: the figure has
a single x link group, so the rows cannot drift apart. The trigger's event span follows it,
and the pre-trigger share is a share of <em>this</em> window.</p>
<section id="reach">
<title>How to reach it</title>
<steps>
<item>
<p>The header bar's window button opens a popover: type a number, pick a unit under
it, or step a decade with the arrows.</p>
</item>
<item>
<p>The button's own label is a reading of the current window in a natural unit,
width-locked so a change of window cannot widen the header bar.</p>
</item>
</steps>
</section>
<section id="knowing">
<title>Worth knowing</title>
<list>
<item><p>A window too long to store at the wire rate is <em>decimated, not refused</em>.
The shared clock takes a power-of-two stride, and each group of samples is
<em>averaged</em> rather than strided, so a tone above the stored Nyquist is attenuated
instead of aliasing to a false low frequency.</p></item>
<item><p>The stamp stored for a group is the group's <em>mean</em>, which is where the
averaging puts the value. Stamping at the end of a group would shift every trace half a
stored sample late.</p></item>
<item><p>Moving the window across a decimation boundary rebuilds the buffer and drops
the history. Walking it inside one decade is free.</p></item>
<item><p><em>This control is the only thing that changes the window.</em> A zoom, a pan
or a fit moves the view <em>inside</em> it and leaves it alone — which is the point of
the previous bullet: a wheel notch that redefined the window could re-plan the buffer
and throw your samples away.</p></item>
<item><p>The trigger sees the <em>stored</em> (filtered) signal, not the wire signal,
because an edge found on a sample that is not in the buffer could not be cut out of
it.</p></item>
</list>
</section>
<section id="example">
<title>Example</title>
<p>1 MS/s with 200 MB of buffer. A 2 s window stores every sample
(<code>time x1</code>). Widen it to 600 s and the stride becomes <code>x16</code>: 625 S/s
stored, anti-aliased at 312.5 Hz. A 2 kHz sine seen through that window reads
<code>y=[-0.059, 0.079]</code> — attenuated to about 6%, not folded down to a
full-amplitude 125 Hz one.</p>
</section>
</page>
+18 -2
View File
@@ -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);
} }
} }
+7
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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
-79
View File
@@ -1,79 +0,0 @@
<b>What udpscope is</b>
udpscope plots a live UDPStreamer source. One window holds up to 8 rows of plots, each with up
to 4 traces, and every row shares one time axis, so the whole figure scrolls together.
Acquisition runs on its own thread and writes a ring buffer sized from <tt>--buffer-mb</tt>;
the drawing thread reads whatever that ring holds when a frame starts, which is why a trace
never makes the window stutter.
The stream is the clock. Every interval you read on screen is measured between timestamps the
source sent, never your machine's, so a stalled source freezes the view instead of scrolling it
-- and a source whose clock steps still gets the window you asked for.
The time axis is <i>relative to now</i>. While streaming it is a fixed <tt>[-window .. 0]</tt>
with the newest sample at 0, so x reads as an age: <tt>-1.5s</tt> is a sample a second and a half
old. In trigger mode 0 is the event's edge instead, and a paused view keeps the ages it was
frozen with. The source's own clock is still there when you need it -- an export writes it as a
second column.
<b>How to reach it</b>
Start the app with a source (<tt>udpscope --server 127.0.0.1 --port 8000</tt>), or start it with
no <tt>--server</tt> and pick one from the chooser, which lists the sessions you have opened
before. A unicast source serves one client at a time: if a second viewer connects, they take
turns and the status bar says so. To watch one source from several instances, use
<tt>--multicast</tt> with the group and <tt>--interface</tt> with the local address.
The signals arrive <i>disabled</i>. Nothing is acquired until you turn one on in the signal
list, and enabling is what starts acquisition -- so a freshly opened scope with an idle side
panel is working correctly, not failing to connect.
<b>The window</b>
• <b>The header bar</b> -- left to right: the time window (<tt>2 s</tt>), the trigger and the
pause, the events, cursors and side-panel switches, and the menu, which holds Fit to data, the
layout editor, Export, Keyboard shortcuts, Help and About.
• <b>The side panel</b> -- two tabs. <b>Measure</b> holds the cursors readout and the
measurements derived from it; <b>Signals</b> holds the trigger and the channel list, where a
math channel is a row of that one list under a <tt>MATH</tt> heading whose <tt>+</tt> creates
one. One row per channel: its colour, an enable switch, its name and its unit. Channels are
dragged from here onto a plot row.
• <b>The plot</b> -- up to 8 rows, each with its own y axis and up to 4 traces, all on one
shared time axis. Right-click a row for its own menu (fit, add a signal, remove a trace).
• <b>The status bar</b>, directly above the plot's bottom edge -- the state word, the stored
sample rate and the window, and the ⓘ beside it for packets, gaps and the buffer's own
figures. The second screenshot above is what a held capture looks like: <tt>event held</tt>,
the axis pinned to the event, and the trigger marker at the edge.
<b>The first five minutes</b>
1. <b>Connect.</b> With no <tt>--server</tt>, pick a stored session or type a host and port.
2. <b>Enable a signal</b> in the signal list. The plot stays empty for the first fraction of a
second while the first window fills -- the buffer holds only what has arrived.
3. <b>Drag it onto a row.</b> Drop a second signal on another row, or the same signal again for
a second view of it. The colour is per signal, so it is the same one wherever it appears.
4. <b>Set the window</b> with the control in the header bar: type a number, pick a unit, or step
a decade. The trigger's event window is a share of this span, so a window change moves it.
5. <b>Pause</b> (<tt>space</tt>) to look at a still picture; the producer keeps filling the
buffer behind it, and resuming shows what arrived meanwhile rather than a hole.
6. <b>Arm the trigger</b> (<tt>a</tt>) and wait for a latch: the view freezes on one event, and
you can zoom in on it. Disarming mid-capture keeps what the capture got.
7. <b>Measure it</b> -- the measurements pane computes a statistic of the visible window, and
<b>show on plot</b> marks it as a ruler or a pair of markers.
8. <b>Save the data</b> with Export visible range, which writes the plotted signals over the
window at the stored rate to <tt>.npy</tt> 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.
-42
View File
@@ -1,42 +0,0 @@
<b>Choosing a source</b>
A source is a host and a port, optionally reached through a multicast group on a named
interface. Pass it on the command line, or let udpscope ask.
<b>How to reach it</b>
Run with <tt>--server</tt> and the app connects straight away. Run <i>without</i>
<tt>--server</tt> and a chooser appears first: one row per stored <i>configuration</i>
(<tt>host:port · name</tt>, its note, and the settings it carries), newest first, plus a
<b>New host…</b> form that takes the address <i>and</i> the configuration it belongs to — its name
and note, the <b>buffer size</b>, and whether time is relative or absolute. A row's pencil button
opens the same form on that configuration, pre-filled, and changes to its name or address move it
(the old file is deleted).
The second page, <b>Waveforms</b>, opens an <i>exported</i> run instead of a live source: the files
opened before (newest first; one that has since been deleted is marked <i>missing</i>) and an
<b>Open…</b> button for a new one. The same file can be opened straight from the command line with
<tt>--open FILE</tt>. A waveform is a source that never arrives — no socket, no acquisition, no
starvation notice — and every instrument works on it: cursors, measurements, math channels and
another export. Only <tt>.npy</tt> files are read. form for one it has not seen. The chooser runs before any
buffer is allocated and before any socket is opened, so declining it costs nothing.
<b>Worth knowing</b>
• The list is the sessions that <i>named a signal</i>. A session whose streamer was down
stores empty slot names and is filtered out, which is what keeps the list the sources you
actually configured rather than every port you have ever typed.
• A stored row reconnects the way it was reached. The multicast group and the interface are
written inside the session file, because the file's name (<tt>host_port.toml</tt>) cannot
carry them.
• <tt>--multicast</tt> still needs <tt>--server</tt>: the CONFIG message is fetched from the
server, not from the group.
• The <tt>×</tt> beside a row deletes that session file. Nothing else is removed.
<b>Example</b>
<tt>udpscope</tt> with two stored sessions lists
<tt>127.0.0.1:8000 plots=1 signals=3</tt> and
<tt>10.0.0.5:8001 plots=2 signals=4 multicast=239.1.2.3 interface=10.0.0.9</tt>.
Activating the first reports <tt>[sources] picked 127.0.0.1:8000</tt> and continues into
the scope with that session's layout already loaded.
-36
View File
@@ -1,36 +0,0 @@
<b>Signals and calibration</b>
The source's CONFIG message describes its signals: a name, a unit and a type. udpscope turns
each enabled signal into a <i>channel</i> — a ring of values beside the shared time ring —
and a channel is what a trace plots. Calibration is a scale and an offset applied at ingest.
<b>How to reach it</b>
The side panel (<tt>s</tt>) lists every signal with a switch. The <b>Signal list</b> item in
the overflow menu opens the editor where the unit and the calibration live.
<b>Worth knowing</b>
• Signals start disabled, and enabling one is what starts acquiring it. A trigger on a signal
nobody acquires can never fire, so arming a trigger enables its signal for you.
• <b>Calibrate</b> in a signal's row menu opens the gain and offset it is <i>currently</i> drawn
through, so a trace already scaled by a gain of 2 shows 2 — pressing <i>Apply</i> confirms it
rather than resetting it. A signal nobody calibrated shows the identity, 1 and 0.
• Calibration is applied <i>at ingest</i>, so changing it re-plans the buffer and drops the
history. So does enabling or disabling a signal, and so does a new CONFIG. An empty plot
for a second after one of those is the ring refilling, not a fault.
• The stream's first signal is usually its <i>timestamp</i>, and that one is never acquired.
It cannot be plotted, measured or used as a math source, and udpscope refuses to put it in
a row rather than binding a trace that would read nothing for ever.
• A signal's colour follows the <i>signal</i>, not the slot, so the same signal on two rows
is one colour and the side panel's chip identifies it unambiguously. A signal on no row
gets a grey chip rather than none, so the panel's rows keep their height.
• <b>Click a coloured chip to change that signal's colour.</b> A grey chip names no trace, so
it is not clickable. The colour is saved with the session; <b>Default colour</b> in the
signal's ⋯ menu gives the palette entry back.
<b>Example</b>
A source declaring <tt>time</tt>, <tt>sine</tt>, <tt>ramp</tt> and <tt>noise</tt> yields three
plottable signals. Enable all three and the exit dump reads <tt>3 signals, 3 channels</tt>;
<tt>time</tt> is not among them.
-39
View File
@@ -1,39 +0,0 @@
<b>Rows, layout and drag &amp; drop</b>
The figure is a stack of rows, 1 to 8 of them, each holding up to 4 traces. Rows share one
time axis and one pair of side margins, so their y labels line up, their data areas start and
end on the same two columns, and their windows cannot drift apart. The left margin holds the y
labels; the right one is empty, and is there so the newest tick's label and the newest sample's
ink have somewhere to land instead of sitting on the frame.
<b>How to reach it</b>
Drag a signal's chip out of the side panel (<tt>s</tt>) and drop it on a row to add a trace.
Press <tt>l</tt>, or pick <b>Layout editor</b> from the overflow menu, to add and remove rows:
a <tt>×</tt> appears at the top left of each row and a <tt>+</tt> on each boundary between
rows, including above the first and below the last. The editor's own button in the top-right
corner leaves it.
<b>Worth knowing</b>
• A plot's own <b>right-click menu</b> (the same compact rows as the app's menu) holds the
y-axis scale, the two fits, and <b>Remove signal</b>, which swaps the menu to that plot's traces
plus <i>All traces on this plot</i> — with a <b>Back</b> row to return. That is how a trace comes
off a row without going through the layout editor.
• A drop <i>adds</i> a trace to the row's next free slot; it does not replace one. With four
traces already on a row the drop is refused.
• Inserting a row moves the rows below it <i>with their contents</i>. Inserting above row 1
puts what row 1 plotted onto row 2 and leaves the new row empty — the rows are not
renumbered under you.
• A trace is remembered by the signal's <i>name</i>, not by its channel number, because a new
CONFIG or an enable renumbers the channels and an index would silently re-point the trace.
• Adding or removing a row rebuilds the figure, which is cheap, but it also re-deals every
trace. A frozen trigger event survives the rebuild: the pinned window is restated rather
than re-framed on the live edge.
<b>Example</b>
With three traces on row 1, pressing <tt>l</tt> shows 3 buttons (one <tt>×</tt>, two
<tt>+</tt>). Clicking the <tt>+</tt> above row 1 gives 2 rows and 5 buttons, with the three
traces now on row 2 — the layout probe reports exactly this as
<tt>rows=2 buttons=5 traces=[0:-|1:sine,ramp,noise]</tt>.
-33
View File
@@ -1,33 +0,0 @@
<b>Time window</b>
The window is how much time the plot shows. Every row shares one window: the figure has a
single x link group, so the rows cannot drift apart. The trigger's event span follows it, and
the pre-trigger share is a share of <i>this</i> window.
<b>How to reach it</b>
The header bar's window button opens a popover: type a number, pick a unit under it, or step a
decade with the arrows. The button's own label is a reading of the current window in a natural
unit, width-locked so a change of window cannot widen the header bar.
<b>Worth knowing</b>
• A window too long to store at the wire rate is <i>decimated, not refused</i>. The shared clock
takes a power-of-two stride, and each group of samples is <b>averaged</b> rather than strided,
so a tone above the stored Nyquist is attenuated instead of aliasing to a false low frequency.
• The stamp stored for a group is the group's <i>mean</i>, which is where the averaging puts the
value. Stamping at the end of a group would shift every trace half a stored sample late.
• Moving the window across a decimation boundary rebuilds the buffer and drops the history.
Walking it inside one decade is free.
• <b>This control is the only thing that changes the window.</b> A zoom, a pan or a fit moves the
view <i>inside</i> it and leaves it alone — which is the point of the previous bullet: a wheel
notch that redefined the window could re-plan the buffer and throw your samples away.
• The trigger sees the <i>stored</i> (filtered) signal, not the wire signal, because an edge
found on a sample that is not in the buffer could not be cut out of it.
<b>Example</b>
1 MS/s with 200 MB of buffer. A 2 s window stores every sample (<tt>time x1</tt>). Widen it to
600 s and the stride becomes <tt>x16</tt>: 625 S/s stored, anti-aliased at 312.5 Hz. A 2 kHz
sine seen through that window reads <tt>y=[-0.059, 0.079]</tt> — attenuated to about 6%, not
folded down to a full-amplitude 125 Hz one.
-59
View File
@@ -1,59 +0,0 @@
<b>Navigating the plot</b>
The plot pans and zooms with the mouse and with the keyboard. Every gesture acts on the row the
pointer is over, and because the rows share one time axis, an x change moves all of them.
<b>How to reach it</b>
<b>Wheel</b> zooms both axes · <b>Shift+wheel</b> zooms x only · <b>Ctrl+wheel</b> zooms y only.
<b>Drag</b> pans · <b>Shift+drag</b> is a box zoom on both axes · <b>Ctrl+drag</b> on x only ·
<b>Alt+drag</b> on values only.
The area being chosen is framed while you drag it, so you can see what the release will apply.
<b>+</b> and <b>-</b> zoom both axes; <b>Ctrl</b> with them restricts to x, <b>Alt</b> to y.
<b>Arrow keys</b> pan, <b>f</b> fits, <b>Escape</b> cancels a gesture in progress.
The full table is in the shortcut overview (<tt>Ctrl+/</tt>).
<b>Worth knowing</b>
• The keyboard zoom's modifiers are <i>not</i> the wheel's, and that is deliberate: on common
layouts <tt>+</tt> <i>is</i> shift+<tt>=</tt>, so a bare zoom-in already arrives with Shift
set and Shift could not mean anything. Ctrl/Alt mirror the box-zoom drags instead.
• A <i>pinned</i> view is navigable within its own data but cannot leave it. While an event is
held, a capture is filling, or the view is paused, a zoom <i>in</i> works normally — "not
outside the event" is not "not readable" — but a pan or a zoom out is clamped to the data.
• Nothing you do on the plot changes the <i>time window</i> — only the header control does. A
zoom or a pan moves the view inside the window; <b>f</b> (and Fit X axis) is the <i>undo</i> of a
zoom, so a 2 s window zoomed into reads 2 s again. The window is more than the axis: it is also
the trigger's event span and how the samples are stored, so a wheel notch that redefined it
would re-plan the buffer and drop your history.
• The axis does not scroll — the <i>data</i> does. A live axis is a fixed <tt>[-window .. 0]</tt>
with the newest sample at 0, so the labels stand still and the trace rolls under them. The
cursors stand still with the labels (they are positions on screen, so a cursor you placed stays
where you put it, over newer data each frame), while a measurement's marks travel with the
sample they measured and leave the view when it does.
• A live view is clamped to the window you asked for, not to what the ring happens to hold.
That is why a buffer that has only just started filling leaves its left-hand side empty
instead of squeezing the window down to the first few samples.
• Zooming hard into a held capture is exact. The renderer finds the visible samples by binary
search over the real timestamps, never over a nominal sample grid, so a capture whose clock
wandered does not drift away from its axis as you zoom.
• A trace holding far more samples than the plot has pixels is drawn <i>one pixel column at a
time</i>: each column is a single vertical line from the lowest to the highest value that falls
in it. Drawn as a polyline instead, such a window puts three or four near-vertical lines in
every column at slightly different sub-pixel positions, and how they overlap beats against the
pixel grid — which reads as bands of light and dark that <i>crawl</i> as you pan. One line per
column removes the beat, and it is also much faster: a 1 MS/s view costs a couple of thousand
line segments a frame instead of a couple of hundred thousand.
• Those columns are <i>graded by brightness</i>, the way a digital-phosphor scope reads: bright
where the signal spent its time, faint where it only passed through. A column's highest and
lowest value are the same whether the samples in it are spread evenly or piled at its two ends,
so the grading is what tells the two apart — the flat parts of a square wave come out solid and
the edges between them faint. It is read from a finer division of each column rather than from
the line that is drawn, and it costs about a millisecond a frame.
<b>Example</b>
Pause a live view, then press <tt>Ctrl++</tt>: x narrows and y does not move. Press
<tt>Alt++</tt>: y narrows and x does not. Eight doublings into a held 2 s capture, the trace
still reaches both margins — the zoom probe reports <tt>gap_px</tt> and <tt>end_px</tt> within
a pixel or two at every step.
-31
View File
@@ -1,31 +0,0 @@
<b>Pause and play</b>
Pause freezes the <i>display</i>, not the acquisition. The producer keeps filling the ring at
full rate; the view binds an immutable snapshot of the window that was on screen, and the axis
is pinned to it.
<b>How to reach it</b>
The pause button in the toolbar, or <tt>Space</tt>. Press it again to resume, which rebinds the
live ring and puts the window back on the newest data.
<b>Worth knowing</b>
• Nothing is lost while paused. Because the producer never stops, resuming shows current data
with no hole in the history behind it.
• The axis is pinned to the <i>snapshot's own stamps</i>, not to the window that was requested.
A window reaching back past the oldest retained sample yields a shorter snapshot, and the
pinned axis is that shorter span — so you see a narrower view rather than an empty margin.
• A paused view is pinned exactly as a held trigger event is: zoom in freely, but a pan or a
zoom out is clamped to the snapshot's data.
• Pausing is the easiest way to measure a layout or read a cursor precisely, because nothing
under the pointer moves between frames.
• The status bar says how long you have been paused — wall time, since the stream itself never
stopped. It is there so a frozen picture cannot be mistaken for a live one that went quiet.
<b>Example</b>
Pause with a 2 s window over a ring holding 1.4 s: the frozen axis reads
<tt>[-1.40 .. 0.00]</tt> rather than <tt>[-2.00 .. 0.00]</tt> with 0.6 s of blank. The pause
probe reports <tt>ring_produced</tt> still growing across the pause and
<tt>event_size</tt> non-zero exactly while paused.
-76
View File
@@ -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/&lt;source&gt;/</tt>) as a <tt>.npy</tt> of the event, a
<tt>.txt</tt> of what it was, and a <tt>.bmp</tt> picture drawn from the samples. The
<b>Events</b> pane on the left -- opened from the toolbar's event-history button, which appears
while recording is enabled, and collapsed until you ask for it -- lists the last 32 newest first
(time, duration, signals, rate), each row with its own picture beside the numbers. Clicking a row
<i>replays</i> it: the stored event is loaded exactly like an exported file, <b>without changing the
layout</b> -- the rows, the colours, the window, the cursors and the measurements stay where they
are and read the stored data instead. A replay leaves trigger mode, because a file has no edge to
wait for, and the pane stays open. Drag the pane's edge to resize it. <b>Live</b> goes back to the
stream; the status bar names the file until you do.
Select two or more events in the list and the two buttons under the header act on them:
<b>Compare</b> puts two events in two plots side by side, keeping the layout you have -- one plot per
event, the axes synchronised, so zooming a row in one zooms the same row in the other -- and
<b>Accumulate</b> overlays up to four of them in the same traces, translucent, so where they agree
the trace is brighter. Your own traces stay where they are in both. They need events with the same
signals and the same length. A single click selects, a <i>double</i> click replays.
<b>Record events</b>, in the trigger panel with the trigger's other options, is what decides whether
anything is kept. It is <b>off by default</b> -- nothing is written to disk until you ask -- and
with it off the history button goes with it.
• Arming <i>enables</i> the signal if it was off, because a trigger on a signal nobody acquires
can never fire.
• The pre-trigger share is a share of the <i>display window</i>, not of the ring. A 2 s window
at 50% captures 1 s before the edge and 1 s after.
• There are four display states, and the status bar names each: <tt>armed</tt> (watching,
nothing drawn yet), <tt>capturing</tt> (the edge was accepted and the post window is filling
<i>in view</i>, with the marker at x = 0), <tt>event held</tt> (the frozen capture) and
<tt>disarmed</tt>. A held capture is never quietly replaced by live data.
• The first edge after arming is only accepted once the history reaches back a full pre-trigger
window, so a capture may legitimately be shorter than you asked for. The axis is pinned to
what the capture actually got.
• Disarming <i>during</i> a capture publishes what the capture got to, so the picture on screen
at that moment stays on screen. No event fired, so the event count does not move.
• The trigger reads the <b>stored</b> signal — decimated and averaged — not the wire signal.
A spike narrower than one decimation group is filtered away rather than triggering on data
the buffer does not contain.
• The four shapes: <b>edge</b> — a crossing of the level in the chosen direction, the classic
behaviour. <b>Pulse width</b> — a crossing and its return, qualified by how long the excursion
lasted: less than, greater than, within or outside a pair of widths; the event is timed at the
closing edge. <b>Window</b> — the signal entering or leaving a band between two levels; the
panel keeps the two levels at least one increment apart, because a zero-width band is not a
band: the condition becomes an exact float-equality test against the level, which an ordinary
signal never satisfies and a signal sitting at the level satisfies on every transition.
<b>Dropout</b> —
the absence of a crossing for longer than a timeout; one event per outage, re-arms when a
crossing returns.
• The shape's own rows are the only ones shown; the rest are hidden, so the panel never offers
a pulse width to a window trigger.
• <b>Search the buffer</b> runs the same condition over the whole retained ring and lists every
place it holds. The arrows step through hits; each step pauses on the hit, centred, exactly as
the Pause button does. Up to 16 hits in the visible window are marked on every row; when there
are more the panel says how many and suggests zooming in. The search uses the trigger's own
condition, set in one place, so a hit is by construction an instant the trigger would have fired.
<b>Example</b>
A 1 Hz sine, level 0, rising, 2 s window at 50%, Single. The capture spans
<tt>[-1.000 .. 1.000]</tt> with <tt>n=20001</tt> samples at 10 kS/s — the span times the real
rate, plus one — and the marker sits at x = 0 throughout.
-50
View File
@@ -1,50 +0,0 @@
<b>Cursors</b>
Cursors read values off the traces. There are two kinds: one <i>shared vertical pair</i> that
every row carries at the same time, and <i>horizontal rulers</i>, one pair per row, that belong
to that row alone.
<b>How to reach it</b>
The cursors button in the toolbar, or <tt>c</tt>. The pane below the plot shows the readings and
owns a <b>Reset</b> button. Drag a marker to move it. The mode dropdown offers four modes:
<tt>vertical</tt> (the shared time pair), <tt>horizontal</tt> (the per-row rulers),
<tt>both</tt>, and <tt>mix</tt>.
<b>Worth knowing</b>
• The vertical pair is <i>one</i> time for the whole figure, so moving it on any row moves it on
all of them. That is what makes <tt>dt</tt> meaningful across rows.
• A ruler belongs to its row and nothing shares it, so two rows' rulers are independent.
• <tt>mix</tt> is the reading mode: one line per <i>bound</i> trace, with the trace's own values
at each marker and their difference. A slot with a name but no channel behind it is not a
trace and gets no line.
• A value is the <i>nearest stored sample's</i>, never a number between two samples. Zoom in past
the sample spacing and the line between two samples is something the plot drew, not data —
so a reading off it would be the drawing. The hover crosshair reads the same way.
• Every reading carries a <i>metric prefix</i> so the digits sit in <tt>1..999</tt>:
<tt>10.52 mV</tt>, not <tt>0.01 V</tt>. Two decimals is all an instrument landing on an
<tt>f32</tt> sample can claim, so without the prefix a small value would read <tt>0.00</tt>
and the reading would be gone. A stream that declares no unit gets the prefix anyway, written
tight against the number (<tt>585.24m</tt>) — the space separates the number from its unit,
and a spaced <tt>m</tt> with nothing after it would read as metres.
• A marker past the newest sample reads <tt>--</tt>. With only one marker over data the row
reads <tt>partial</tt>: a difference between a number and nothing is not a number.
• In <tt>mix</tt> each marker also places a crossing annotation per trace, which is what lets
you see on the plot which sample a number came from. It is <i>on that sample</i>, so when the
marker falls between two it sits a little off its own line: the line is where you put the
marker, the dot is the datum being quoted. The hover crosshair's mark snaps the same way, and
is drawn larger than a trace's sample dots so it does not read as one more sample.
• The time row exists only in the modes that show the vertical pair. <tt>horizontal</tt> has no
time row, by design.
• A cursor <i>stays where you put it</i> while a live view rolls under it, which is what a bench
scope does: its <tt>T0</tt> is an age, so a marker left at <tt>-1.5s</tt> keeps reading the
sample a second and a half old as the stream advances. A measurement's marks do the opposite —
they travel with the sample they measured — because a cursor is a question you asked and a mark
is an answer about one particular sample.
<b>Example</b>
Three traces in <tt>mix</tt> mode with both markers over data: 3 lines and 6 crossing marks,
each line reading like <tt>sine 0.31 V -0.84 V -1.15 V</tt>. Move the second marker past the
newest sample and the third column reads <tt>--</tt>.
-60
View File
@@ -1,60 +0,0 @@
<b>Measurements</b>
A measurement is a statistic of the <i>visible window</i> of one signal, recomputed as that
window moves. The pane lists them with their values; each can also draw its result on the plot.
<b>How to reach it</b>
The measurements button in the toolbar, or <tt>m</tt>. The pane's <tt>+</tt> adds one; clicking
a row opens the dialog where the signal, the type, the threshold share and the on-plot marks are
set, with <b>Save</b> and <b>Delete</b>.
<b>Worth knowing</b>
• The types are average, top, bottom, min, max, peak to peak, amplitude, pulse duration, period
and frequency. <i>top</i> and <i>bottom</i> are the means of the upper and lower halves, which
is why a unit sine reads 0.637 (2/π) rather than 1.
• The on-plot marks come in two kinds and the mix is the reading: a <i>level</i> mark is a ruler
at a value, a <i>time</i> mark is a marker at a position. An average draws one level mark; a
max draws a level mark <i>and</i> a time mark at the first sample that reached it.
• Marks cannot be dragged. A measurement mark says what the measurement measured, so it is
created unselectable — as is the trigger marker.
• A pass is throttled to a cadence rather than run every frame, so a ruler over a live view is
<i>stepped</i>. The number and the ruler come from the same pass, so they always agree.
• An invalid pass keeps its <i>level</i> marks and drops its <i>time</i> marks. A level is a
property of the signal and outlives the window; a position inside the window does not. That is
what stops the rulers blinking off at a mode change.
• A crossing is counted with <i>hysteresis</i>, not on a bare level: the thresholds are a tenth of
the window's peak to peak either side of the share, and an edge is counted only once the signal
has passed the far one. Period, frequency and pulse duration all depend on it. The mid level is
exactly where a waveform moves fastest, so any interferer faster than the fundamental recrosses a
single level several times per real crossing — a 5 Hz sine with a 500 Hz tone at a tenth of the
amplitude reads 39 Hz without the band and 5 Hz with it. The band holds to an interferer a third
of the waveform's own size.
• The band is clamped to stay inside the data, which is what keeps a threshold set near an extreme
usable: a fixed band there would sit above every sample and the measurement would read nothing.
A share near an extreme still leaves the band little room, so it rejects less — which is one
reason period and frequency offer no share to set.
• A window with too few crossings reads <tt>invalid</tt> / <tt>--</tt> rather than a plausible
invented number. Frequency over a window holding one edge is the usual case.
• <tt>frequency</tt> is reported in Hz and <tt>period</tt>/<tt>pulse duration</tt> in seconds;
every other type carries the channel's own unit. A value is written with a <i>metric prefix</i>
so its digits sit in <tt>1..999</tt> — <tt>636.62 mV</tt>, not <tt>0.64 V</tt> — including when
the stream declares no unit at all, which is always the case for a math channel: there the
prefix goes tight against the number (<tt>636.62m</tt>), since two decimals of an unprefixed
small value is <tt>0.00</tt>.
• Cost is bounded by reading the summary pyramid the producer already maintains, not the raw
window. The values are still exact: a bucket's sum is a sum.
• <b>Statistics</b> adds a second line to every measurement showing <tt>n</tt>, the mean, the
standard deviation and the range. Each observation is one <i>recompute</i> of the pane, so
<tt>n</tt> counts windows measured, not samples — a mean frequency over many windows is what
that claims to be. <b>Reset</b> clears every accumulator and starts fresh. A change of display
window, a CONFIG or a measurement edit also resets; a pause, a resume, a zoom, a pan and the
toggle itself do not, so turning the line off to read the plot and back on again keeps the history.
<b>Example</b>
On a 1 Hz ±1 sine: <tt>average=0</tt>, <tt>top=0.636620</tt>, <tt>bottom=-0.636620</tt>,
<tt>max=1</tt>, <tt>min=-1</tt>, <tt>peak to peak=2</tt>, <tt>amplitude=1</tt>,
<tt>pulse duration=0.5 s</tt> (duty 0.5), <tt>period=1 s</tt>, <tt>frequency=1 Hz</tt>. Over a
2 M-sample window those two passes read about 0.1% of the samples.
-61
View File
@@ -1,61 +0,0 @@
<b>Math channels</b>
A math channel is a signal computed from other signals. You build it as a small graph — source
nodes, operations, one output — and udpscope then treats the result as an ordinary channel.
<b>How to reach it</b>
The math button in the toolbar, or <tt>g</tt>. The pane's <tt>+</tt> adds a channel, and opening
one shows the graph editor: nodes as boxes, click a box to select it and edit its parameters beside
the canvas. Inside the editor, <i>add node</i> is a menu button whose popover picks the op, so the
node arrives as the operation you asked for.
<b>Graph gestures:</b> <b>drag</b> from an output port to an input port to wire them; drag a
wired input port off its connection to detach it; click a wire and press <tt>Delete</tt> to remove
it; drag a box to move it. <b>Wheel</b> zooms about the pointer. <b>Drag the background</b> to
pan. <tt>f</tt> fits all nodes into view; <tt>Ctrl+0</tt> resets the view. <i>tidy</i>, the button
beside <i>fit</i> in the editor, lays the graph out in topological columns and frames it. The orange
dot marks the output node; click <i>set as output</i> to move it.
<b>Worth knowing</b>
• The graph is evaluated as an ordered list, so a node may only read ones <i>above</i> it. A
wire drawn right to left re-sorts the list silently so the graph stays valid. A cycle is
unrepresentable: the connection is refused rather than created, and the line beneath the form
says why — a loop, or a node asked to read itself.
• A derived channel <i>is</i> a channel. It appears in the signal panel, can be plotted,
measured, exported, and used as a source for another math channel.
• The math runs on the <b>stored</b> samples — decimated and averaged — so a cutoff you type in
Hz means the rate the filter will actually run at. Change the window across a decimation
boundary and the coefficients are rebuilt for the new rate.
• A source may only name an <i>acquired</i> channel. The stream's timestamp signal is not one:
a graph sourced from it validates and then reads NaN for ever, so the dropdown does not
offer it.
• The output defaults to the <i>last</i> node, and a node added to a linear graph becomes the
output. Choosing an earlier node explicitly is respected.
• Every numeric field starts at its own identity value — gain 1, offset 0 — not at the bottom
of its range.
<b>Example</b>
<tt>[sine] → [gain x2]</tt> gives a channel reading <tt>y=[-2, 2]</tt>. A 100 Hz low pass on the
same source passes 50 Hz at 0.97 and 2 kHz at 0.002; a 1 kHz band pass with Q=2 passes 200 Hz at
0.05, 1 kHz at 0.95 and 4 kHz at 0.05. Each derived channel must hold exactly as many samples as
the shared clock has time slots.
<b>Why an operation sometimes does nothing</b>. A graph's <i>output</i> is the node whose value
becomes the channel, and it is drawn as the orange dot. A new graph is built source, then operation,
and the last node added becomes the output -- but a graph whose output is left on the source is
valid and simply passes its input through, which is the usual reason a gain or a filter appears to
have no effect. <i>Set as output</i> moves the dot.
<b>Apply is insensitive while the graph is incomplete.</b> A line beneath the form says why:
an input that is not connected, no output chosen, or a <i>channel</i> node naming a signal the
scope is not acquiring. Only the channels being acquired are offered, because a source that is not
acquired reads as no data for ever — and a graph that reads NaN is harder to diagnose than one that
would not save.
<b>The filters are real filters.</b> A math channel's samples are produced as the stream arrives,
once per stored slot, so a low pass carries the history a filter needs. A cutoff typed in hertz is
against the <i>stored</i> rate, which is the wire rate until a wide window makes the buffer decimate
-- the status line's rate is that number, and the filters are rebuilt whenever it changes.
-33
View File
@@ -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>.
-60
View File
@@ -1,60 +0,0 @@
<b>Sessions and settings</b>
udpscope stores named sessions — <i>configurations</i> — per source, so reopening a source gives
you the scope you closed, and one source can hold several arrangements for different experiments.
There is no global preferences file: the session <i>is</i> the settings.
<b>How to reach it</b>
Nothing to open — the file is written as you work. Its path is in the About dialog, and the
directory is <tt>$XDG_CONFIG_HOME/udpscope</tt>, or <tt>~/.config/udpscope</tt> when that is
unset. Each source has a directory <tt>&lt;host&gt;_&lt;port&gt;/</tt> holding one
<tt>&lt;name&gt;.toml</tt> per configuration and an <tt>active</tt> file naming the one it is on.
<b>Configurations</b>
<b>Configurations…</b> in the menu lists the running source's configurations: click a row to open
it, <tt>×</tt> to delete it, or type a name and a note and press <i>Save this session</i> to keep
the current arrangement under a new one. Opening a configuration saves the current session first,
so switching never loses what it switches from.
The startup chooser (start with no <tt>--server</tt>) lists one row per configuration, with its
note and its settings, so two arrangements of one source are told apart before they open.
A configuration also carries the settings that can only be decided when the buffers are
allocated: the <b>buffer size</b> (<tt>--buffer-mb</tt>), relative time, and the packet capture's
ring size. Opening a configuration from the chooser applies all three; switching inside a running
app applies the rest and says in the status line that these take effect on the next start.
The chooser's form sets the first two for a new host, and its pencil button edits them on an
existing configuration — along with the name and the note. Renaming a configuration, or editing
the address it points at, moves it: the old file is removed rather than left behind.
<b>Worth knowing</b>
• What a configuration persists: the time window, the rows and each row's slot signals, the
calibration, the trace colours, which signals are enabled, the math channels with their graphs,
the measurements pane — and the startup settings: buffer size, relative time and the packet
capture's ring size.
• Signal identity is the <i>name</i> throughout, because a new CONFIG renumbers the channels.
• A file written by a run against a source whose streamer was down would name no signals at all,
so such a run does <b>not</b> write: a configured session is never overwritten by a run that
configured nothing. (The chooser hides such a file for the same reason.)
• The multicast group and the interface live <i>inside</i> the file, because its name cannot
carry them. A file from an older build has neither key and loads as unicast on the default
route.
• The chooser lists only configurations that name at least one signal, which is what keeps it the
sessions you configured rather than every port you have ever tested against.
• Deleting a configuration is a supported way to start over; the chooser's <tt>×</tt> and the
dialog's do exactly that.
• A session from before configurations existed is imported once as the configuration
<tt>default</tt>, and the old file is kept beside the directory as
<tt>&lt;host&gt;_&lt;port&gt;.toml.imported</tt>.
<b>Example</b>
Connect to <tt>127.0.0.1:8000</tt>, drop three signals on row 1, set a 2 s window, and save the
session as <tt>bench</tt> with a note. <tt>~/.config/udpscope/127.0.0.1_8000/bench.toml</tt> now
holds <tt>window_s=2 comment="…"</tt> with <tt>signals = ["sine", "ramp", "noise", ""]</tt>, and
the next run reports <tt>layout loaded from …/127.0.0.1_8000/bench.toml</tt> with those signals
already acquired.
-46
View File
@@ -1,46 +0,0 @@
<b>Status bar and diagnostics</b>
The status bar is two tiers. The bar itself is one glance line — the state, the rate and the
window — and everything else is behind the ⓘ button at its right-hand end.
<b>How to reach it</b>
The bar is always visible under the plot. Click <b>ⓘ</b> for the detail popover.
<b>Worth knowing</b>
• The badge is one glyph in four colours: green <i>alternating</i> lit and dim means data is
arriving, and a steady colour means it is not. A badge that never blinks is not a working badge.
Of the two steady colours, <i>grey</i> is no stream at all or a pipeline you stopped (the
tooltip says which), and <i>yellow</i> is a source that answered but has sent no configuration.
• The state field is one word from a fixed set: <tt>connecting</tt>, <tt>live</tt>,
<tt>paused</tt>, <tt>armed</tt>, <tt>capturing</tt>, <tt>event held</tt>, <tt>disarmed</tt>. Two
states add one field each: in trigger mode, how long ago the last event was; while paused, how
long you have been paused.
• <tt>connecting</tt> means there is no <i>signal list</i> yet — either no session or a session
with no configuration behind it. Every other word claims a signal list, so until one arrives
there is nothing for them to describe: nothing can be enabled and no data is owed.
• The paused figure is <i>wall</i> time, not stream time — the producer never stops while paused,
so it answers "how long have I been looking at this picture".
• Nothing on the bar may change width with the stream, because the bar sits directly above the
plot and a wider label would relayout it. That is why the rate collapses to one figure or a
range rather than listing per channel, and why an elapsed time steps to a coarser unit instead
of growing digits.
• <tt>gaps</tt> counts packets the receiver knows it missed. It is the number that matters: a
non-zero count means samples are absent from the buffer, not merely late.
• <tt>reconnects</tt> counts sessions re-established with the source. Over unicast a climbing
count usually means a second client is taking the source's single session.
• The starvation notice — <i>connected, but no data is arriving</i> — is that same situation seen
from the other side: the signal list is here, so something took the stream away. A source that
has sent <i>no</i> configuration gets a different notice, <i>trying to connect</i>, because
nothing was taken away — pointing the app at a port where nothing answers is that case, and the
usual cause is a streamer that is not running or is serving another port.
• <tt>stored</tt> versus <tt>wire</tt> in the popover is the decimation. They differ exactly
when the window is too long to store at full rate.
<b>Example</b>
The bar reads <tt>● live · 10 kS/s · 2 s window</tt>. Behind ⓘ:
<tt>Stream connected, configured / packets 717 · frames 716 · gaps 0 / 3 signals · 3 channels</tt>,
<tt>Buffer 200.0 MB · 1677 s span · time x1 / stored 10 kS/s (wire 10 kS/s)</tt>,
<tt>Plot 1 3 traces · 195000 samples</tt>, <tt>Reconnects 0</tt>.
-34
View File
@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
SPDX-License-Identifier: MIT
The help pages, compiled into the binary for the same reason the icons are: udpscope is
run straight out of the build tree and has no install step, so a search path would have
to be located at runtime and the failure mode of not locating it is a help window full
of missing pages.
-->
<gresources>
<gresource prefix="/org/udpscope/help">
<file>01-overview.txt</file>
<file>02-source.txt</file>
<file>03-signals.txt</file>
<file>04-layout.txt</file>
<file>05-window.txt</file>
<file>06-navigate.txt</file>
<file>07-pause.txt</file>
<file>08-trigger.txt</file>
<file>09-cursors.txt</file>
<file>10-measurements.txt</file>
<file>11-math.txt</file>
<file>12-export.txt</file>
<file>13-sessions.txt</file>
<file>14-status.txt</file>
<!-- The screenshots live with the user documentation (docs/); the resource aliases
them under `img/` so a help page can name one by resource path. The path is relative
to this manifest's directory. -->
<file alias="img/main-window.png">../../docs/img/main-window.png</file>
<file alias="img/trigger-capture.png">../../docs/img/trigger-capture.png</file>
<file alias="img/math-editor.png">../../docs/img/math-editor.png</file>
</gresource>
</gresources>
+140 -201
View File
@@ -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
View File
@@ -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
+62
View File
@@ -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";
+30
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+14
View File
@@ -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:
+10
View File
@@ -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();
} }
+9
View File
@@ -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
View File
@@ -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"},
}; };
+2
View File
@@ -17,6 +17,8 @@ enum class AppAction {
Export, Export,
Help, Help,
Shortcuts, Shortcuts,
GuidedTour,
ContextualHelp,
Quit, Quit,
}; };
+389 -6
View File
@@ -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
View File
@@ -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));
-93
View File
@@ -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
View File
@@ -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">&lt;Control&gt;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">&lt;Control&gt;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
View File
@@ -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>
+46 -7
View File
@@ -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
View File
@@ -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">
+3 -3
View File
@@ -2,8 +2,9 @@
<!-- SPDX-License-Identifier: MIT --> <!-- SPDX-License-Identifier: MIT -->
<!-- SPDX-FileCopyrightText: 2026 Martino Ferrari --> <!-- SPDX-FileCopyrightText: 2026 Martino Ferrari -->
<!-- <!--
The application's UI files, compiled into the binary beside the icons and the help pages, so there The application's UI files, compiled into the binary beside the icons, so there is still exactly
is still exactly one file to run. `preprocess="xml-stripblanks"` is what catches a malformed file: one file to run. (The manual is no longer a bundle here: it is a Mallard set installed for Yelp.)
`preprocess="xml-stripblanks"` is what catches a malformed file:
gtk_builder_new_from_resource() on a broken .ui returns nothing usable and the window simply does gtk_builder_new_from_resource() on a broken .ui returns nothing usable and the window simply does
not open, which is a silent failure at runtime and a loud one here. not open, which is a silent failure at runtime and a loud one here.
--> -->
@@ -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
View File
@@ -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);
+344
View File
@@ -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
+4
View File
@@ -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};
+2
View File
@@ -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,
+85
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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(),
+109
View File
@@ -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"
+236
View File
@@ -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"
+46 -5
View File
@@ -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: